Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions apps/pi-extension/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -1031,6 +1057,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: "<!doctype html><html><body>review</body></html>",
});

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-");
Expand Down
89 changes: 89 additions & 0 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ import {
SemanticDiffResponseCache,
} from "../generated/semantic-diff.ts";
import type { SemanticDiffAvailability, SemanticDiffResponse } from "../generated/semantic-diff-types.ts";
import {
buildEslintCheckInput,
getEslintCheckAvailability,
isEslintCheckCompatibleReviewView,
runEslintCheck,
} from "../generated/eslint-check.ts";
import type { EslintCheckAdvert, EslintCheckResponse } from "../generated/eslint-check-types.ts";
import { CallFlowService } from "../generated/call-flow.ts";
import { CallFlowInstallCoordinator, callFlowInstallOriginAllowed } from "../generated/call-flow-install.ts";
import { parseCallFlowInstallRequest, resolveCallFlowInstallTargets } from "../generated/call-flow-languages.ts";
Expand Down Expand Up @@ -1010,6 +1017,77 @@ export async function startReviewServer(options: {
});
}

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();
return input ? getEslintCheckAvailability(input) : { available: false, reason: eslintCheckUnavailableReason() };
}

interface EslintCheckBaseline {
snapshotId: string;
fingerprintGeneration: number;
fingerprint: string | null;
}

async function resolveEslintCheckBaseline(requestedSnapshotId: string | undefined): Promise<EslintCheckBaseline | null> {
if (requestedSnapshotId !== currentSnapshotId()) return null;
const baselineGeneration = fingerprintGeneration;
let baseline = currentFingerprint;
if (pendingFingerprintCapture) baseline = await pendingFingerprintCapture;
if (baselineGeneration !== fingerprintGeneration || requestedSnapshotId !== currentSnapshotId()) return null;
const freshFingerprint = await computeDiffFingerprint();
if (baseline && freshFingerprint && baseline !== freshFingerprint) 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<EslintCheckResponse> {
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;
}

async function getCallFlow(url: URL): Promise<CallFlowResponse> {
const requestedSnapshot = url.searchParams.get("snapshot");
if (!requestedSnapshot || requestedSnapshot !== currentSnapshotId()) {
Expand Down Expand Up @@ -1998,6 +2076,7 @@ export async function startReviewServer(options: {
...(baseBehindRemote && { baseBehindRemote: true }),
...(servedError && { error: servedError }),
semanticDiff: await getSemanticDiffAdvert(servedDiffType as DiffType),
eslintCheck: getEslintCheckAdvert(),
callFlow: await getCallFlowAdvert(servedDiffType as DiffType),
serverConfig: getServerConfig(gitUser),
});
Expand Down Expand Up @@ -2082,6 +2161,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/call-flow" && req.method === "GET") {
// A throw here must never escape the handler: on Node it becomes an
// unhandled rejection, and Pi's process-level handler exits the whole
Expand Down Expand Up @@ -2272,6 +2356,7 @@ export async function startReviewServer(options: {
hideWhitespace: currentHideWhitespace,
...(currentError ? { error: currentError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
eslintCheck: getEslintCheckAdvert(),
callFlow: await getCallFlowAdvert(),
});
return;
Expand Down Expand Up @@ -2392,6 +2477,7 @@ export async function startReviewServer(options: {
...(updatedContext ? { gitContext: updatedContext } : {}),
...(currentError ? { error: currentError } : {}),
semanticDiff: switchSemanticDiff,
eslintCheck: getEslintCheckAdvert(),
callFlow: switchCallFlow,
});
} catch (err) {
Expand Down Expand Up @@ -2493,6 +2579,7 @@ export async function startReviewServer(options: {
...(layerPatchIncomplete ? { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable } : {}),
...((currentError ?? upgradeError) ? { error: currentError ?? upgradeError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
eslintCheck: getEslintCheckAdvert(),
callFlow: await getCallFlowAdvert(),
});
return;
Expand Down Expand Up @@ -2530,6 +2617,7 @@ export async function startReviewServer(options: {
snapshotId: currentSnapshotId(),
prDiffScope: currentPRDiffScope,
semanticDiff: await getSemanticDiffAdvert(),
eslintCheck: getEslintCheckAdvert(),
callFlow: await getCallFlowAdvert(),
});
} catch (err) {
Expand Down Expand Up @@ -2625,6 +2713,7 @@ export async function startReviewServer(options: {
...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }),
...(currentError ? { error: currentError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
eslintCheck: getEslintCheckAdvert(),
callFlow: await getCallFlowAdvert(),
});
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion apps/pi-extension/vendor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 generated-files cli-pagination jj-core gitbutler-core vcs-core review-args draft annotate-history pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common markdown-extensions resolve-file file-browser-watch-core annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args annotate-target at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff call-flow-types call-flow-languages call-flow-pack-locks call-flow-install-lock call-flow call-flow-install single-flight source-save-node review-profiles guide-store guide-instructions-store commit-avatars commit-history port-range annotate-client-lease annotate-decision archive-mode tailscale; do
for f in prompts review-core generated-files cli-pagination jj-core gitbutler-core vcs-core review-args draft annotate-history pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common markdown-extensions resolve-file file-browser-watch-core annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args annotate-target 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 call-flow-types call-flow-languages call-flow-pack-locks call-flow-install-lock call-flow call-flow-install single-flight source-save-node review-profiles guide-store guide-instructions-store commit-avatars commit-history port-range annotate-client-lease annotate-decision archive-mode tailscale; do
src="../../packages/shared/$f.ts"
# Shared modules that import browser-safe siblings from @plannotator/core
# (e.g. guide-store → core/guide-format): generated/ is flat and vendors the
Expand Down
4 changes: 2 additions & 2 deletions packages/core/guide-viewer-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import type { GuideViewerAssets } from "./guide-format";

export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
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",
Expand Down
Loading