From c3ba55e88901f6441e4eb7a6969d45abbcd43cd7 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 8 Jun 2026 22:23:54 -0700 Subject: [PATCH] feat(review): add semantic diff overview (#871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(review): add semantic diff overview * fix(review): address semantic diff review findings * fix(review): harden semantic diff fallback * fix(review): avoid repo-local sem execution * fix(review): normalize semantic diff cwd * test(review): cover semantic diff local cwd * fix(gitlab): fetch raw MR diffs * fix(review): harden sem path resolution * docs(review): add semantic diff handoff * feat(review): semantic diff cards, header sem badges, jump-to-line - Restyle the semantic panel as real bordered cards (shadcn surface) instead of ASCII box-drawing; centered column, grid-aligned entity rows. - Hide orphan (module-level) changes from the rows, matching sem's default; the count still surfaces in the summary line. - Add a 'sem · N' hover popover to each diff file header showing that file's semantic changes, reusing the same row component as the panel. - Clicking an entity (panel or popover) scrolls the diff to the lines via pierre's [data-selected-line]; centers only when off-screen so manual drag-selection isn't disturbed. - Extract shared rows/helpers into semanticDiffShared; share one cached /api/semantic-diff fetch across header badges. * feat(review): land on All files by default Semantic diff stays available via the file-tree nav entry and header badges, but it's no longer the initial landing view. * chore(review): drop dead change-symbol entries, log badge fetch failures - Remove unreachable 'moved'/'renamed' entries from the changeSymbols table (getChangeSymbol early-returns for those before the lookup). - Log a console.error once per patch when the file-header badge's semantic diff fetch fails or returns a non-ok status, so a systemic failure leaves a trace instead of every badge silently showing nothing. * feat(review): flatten semantic diff panel into grouped list - Dissolve the per-file bordered cards into flat sections: the file path is now a quiet underlined header (single hairline) with entity rows flush beneath, hierarchy from whitespace + hover tint instead of boxes. - Split the path into muted directory + emphasized filename for faster scanning. - Nudge the add/remove glyphs (⊕/⊖) ~15% larger, line-height pinned so rows don't grow; modified/rename/reorder glyphs unchanged. --- AGENTS.md | 9 +- apps/pi-extension/server.test.ts | 217 +++++++- apps/pi-extension/server/serverReview.ts | 81 +++ apps/pi-extension/vendor.sh | 2 +- docs/semantic-diff-handoff.md | 340 ++++++++++++ packages/review-editor/App.tsx | 104 +++- .../review-editor/components/DiffViewer.tsx | 41 ++ .../review-editor/components/FileHeader.tsx | 2 + .../review-editor/components/FileTree.tsx | 21 +- .../components/SemanticFileBadge.tsx | 98 ++++ .../review-editor/dock/ReviewStateContext.tsx | 11 + .../dock/panels/ReviewSemanticDiffPanel.tsx | 188 +++++++ .../dock/panels/semanticDiffShared.tsx | 155 ++++++ .../dock/reviewPanelComponents.ts | 2 + .../review-editor/dock/reviewPanelTypes.ts | 2 + .../hooks/useFileSemanticChanges.ts | 84 +++ packages/review-editor/hooks/usePRStack.ts | 2 + packages/review-editor/index.css | 232 ++++++++ packages/server/review-workspace.test.ts | 245 ++++++++- packages/server/review.ts | 83 +++ packages/shared/package.json | 4 +- packages/shared/pr-gitlab.test.ts | 128 +++-- packages/shared/pr-gitlab.ts | 109 +--- packages/shared/semantic-diff-types.ts | 76 +++ packages/shared/semantic-diff.test.ts | 322 +++++++++++ packages/shared/semantic-diff.ts | 520 ++++++++++++++++++ scripts/install.cmd | 106 ++++ scripts/install.ps1 | 70 +++ scripts/install.sh | 102 ++++ scripts/install.test.ts | 27 + 30 files changed, 3214 insertions(+), 169 deletions(-) create mode 100644 docs/semantic-diff-handoff.md create mode 100644 packages/review-editor/components/SemanticFileBadge.tsx create mode 100644 packages/review-editor/dock/panels/ReviewSemanticDiffPanel.tsx create mode 100644 packages/review-editor/dock/panels/semanticDiffShared.tsx create mode 100644 packages/review-editor/hooks/useFileSemanticChanges.ts create mode 100644 packages/shared/semantic-diff-types.ts create mode 100644 packages/shared/semantic-diff.test.ts create mode 100644 packages/shared/semantic-diff.ts diff --git a/AGENTS.md b/AGENTS.md index 8d535c03..98e887a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -285,8 +285,9 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | -| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, mode?, diffType, base, hideWhitespace, gitContext, agentCwd? }`. Workspace mode returns `mode: "workspace"` with folder-prefixed paths and no `gitContext`. | -| `/api/diff/switch` | POST | Switch diff type, base branch, or whitespace mode (body: `{ diffType, base?, hideWhitespace? }`) | +| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, mode?, diffType, base, hideWhitespace, gitContext, agentCwd?, semanticDiff? }`. Workspace mode returns `mode: "workspace"` with folder-prefixed paths and no `gitContext`. | +| `/api/diff/switch` | POST | Switch diff type, base branch, or whitespace mode (body: `{ diffType, base?, hideWhitespace? }`). Response includes `semanticDiff?`. | +| `/api/semantic-diff` | GET | Runs semantic diff for the active patch and returns parsed sem output or an unavailable/error response (`?fileExt=` / `?fileExts=` optional). | | `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context (`?path=&oldPath=&base=`) | | `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) | | `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) | @@ -312,9 +313,9 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | `/api/agents/jobs` | POST | Launch an agent job (body: `{ provider, command, label }`) | | `/api/agents/jobs` | DELETE | Kill all running agent jobs | | `/api/agents/jobs/:id` | DELETE | Kill a specific agent job | -| `/api/pr-diff-scope` | POST | Switch between layer and full-stack diff scope | +| `/api/pr-diff-scope` | POST | Switch between layer and full-stack diff scope. Response includes `semanticDiff?`. | | `/api/pr-list` | GET | List PRs for the current repo (cached 30s) | -| `/api/pr-switch` | POST | Switch to a different PR in-place (body: `{ url }`) | +| `/api/pr-switch` | POST | Switch to a different PR in-place (body: `{ url }`). Response includes `semanticDiff?`. | | `/api/tour/:jobId` | GET | Fetch Code Tour result (greeting, stops, checklist) for a completed tour job | | `/api/tour/:jobId/checklist` | PUT | Persist checklist item state for a Code Tour | | `/api/code-nav/resolve` | POST | Search for symbol definitions and references via ripgrep (body: `{ symbol, filePath, line, charStart, side, language? }`) | diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index be371fb5..5fede8a7 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -23,6 +23,8 @@ const originalCwd = process.cwd(); const originalHome = process.env.HOME; const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; const originalPort = process.env.PLANNOTATOR_PORT; +const originalSemPath = process.env.PLANNOTATOR_SEM_PATH; +const originalDataDir = process.env.PLANNOTATOR_DATA_DIR; function makeTempDir(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); @@ -68,6 +70,47 @@ function initRepo(): string { return repoDir; } +function makeMockSem(dir: string, options: { + runCwdLogPath?: string; + inputLogPath?: string; +} = {}): string { + const semPath = join(dir, "sem"); + writeFileSync( + semPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'if [ "${1:-}" = "--version" ]; then', + ' echo "sem 0.8.0"', + " exit 0", + "fi", + ...(options.runCwdLogPath ? [`pwd >> ${JSON.stringify(options.runCwdLogPath)}`] : []), + ...(options.inputLogPath ? [`cat > ${JSON.stringify(options.inputLogPath)}`] : ["cat >/dev/null"]), + "cat <<'JSON'", + JSON.stringify({ + summary: { fileCount: 1, added: 1, modified: 0, deleted: 0, moved: 0, renamed: 0, reordered: 0, binary: 0, orphan: 0, total: 1 }, + changes: [ + { + entityId: "src/app.ts::function::created", + changeType: "added", + entityType: "function", + entityName: "created", + filePath: "src/app.ts", + startLine: 1, + endLine: 3, + }, + ], + binaryChanges: [], + }), + "JSON", + "", + ].join("\n"), + "utf-8", + ); + chmodSync(semPath, 0o755); + return semPath; +} + function initJjRepo(): string { const repoDir = initRepo(); writeFileSync(join(repoDir, "spacey.ts"), "const x = 1;\n", "utf-8"); @@ -131,6 +174,16 @@ afterEach(() => { } else { process.env.PLANNOTATOR_PORT = originalPort; } + if (originalSemPath === undefined) { + delete process.env.PLANNOTATOR_SEM_PATH; + } else { + process.env.PLANNOTATOR_SEM_PATH = originalSemPath; + } + if (originalDataDir === undefined) { + delete process.env.PLANNOTATOR_DATA_DIR; + } else { + process.env.PLANNOTATOR_DATA_DIR = originalDataDir; + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -139,6 +192,148 @@ afterEach(() => { describe("pi review server", () => { const testIfJj = hasJj() ? test : test.skip; + const semanticRawPatch = [ + "diff --git a/src/app.ts b/src/app.ts", + "new file mode 100644", + "index 0000000..1111111", + "--- /dev/null", + "+++ b/src/app.ts", + "@@ -0,0 +1,3 @@", + "+export function created() {", + "+ return true;", + "+}", + "", + ].join("\n"); + + 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-"); + const cwdLogPath = join(dir, "cwd-log"); + process.env.PLANNOTATOR_DATA_DIR = dataDir; + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath }); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startReviewServer({ + rawPatch: semanticRawPatch, + gitRef: "test", + origin: "pi", + htmlContent: "review", + }); + + try { + const diffPayload = await fetch(`${server.url}/api/diff`).then((response) => response.json()) as { + semanticDiff?: { available: boolean; semVersion?: string; semSource?: string }; + }; + expect(diffPayload.semanticDiff).toMatchObject({ + available: true, + semVersion: "0.8.0", + semSource: "env", + }); + + const semanticPayload = await fetch(`${server.url}/api/semantic-diff?fileExt=.ts`).then((response) => response.json()) as { + status: string; + summary?: { added: number; fileCount: number }; + changes?: Array<{ entityType: string; entityName: string; filePath: string }>; + }; + expect(semanticPayload).toMatchObject({ + status: "ok", + summary: { added: 1, fileCount: 1 }, + changes: [ + { entityType: "function", entityName: "created", filePath: "src/app.ts" }, + ], + }); + expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe( + realpathSync(join(dataDir, "semantic-diff", "patch-only")), + ); + } finally { + server.stop(); + } + }); + + test("runs semantic diff from the local agent cwd when one is available", async () => { + const dir = makeTempDir("plannotator-pi-sem-agent-"); + const agentCwd = makeTempDir("plannotator-pi-sem-agent-cwd-"); + const cwdLogPath = join(dir, "cwd-log"); + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath }); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startReviewServer({ + rawPatch: semanticRawPatch, + gitRef: "test", + origin: "pi", + agentCwd, + htmlContent: "review", + }); + + try { + const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as { + status: string; + }; + expect(semanticPayload.status).toBe("ok"); + expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(agentCwd)); + } finally { + server.stop(); + } + }); + + test("runs semantic diff from the local git context cwd in local review mode", async () => { + const dir = makeTempDir("plannotator-pi-sem-local-"); + const repoDir = initRepo(); + const cwdLogPath = join(dir, "cwd-log"); + const gitContext = await getVcsContext(repoDir); + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath }); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startReviewServer({ + rawPatch: semanticRawPatch, + gitRef: "test", + origin: "pi", + diffType: "unstaged", + gitContext, + htmlContent: "review", + }); + + try { + const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as { + status: string; + }; + expect(semanticPayload.status).toBe("ok"); + expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(repoDir)); + } finally { + server.stop(); + } + }); + + test("hides semantic diff from /api/diff when sem cannot be resolved", async () => { + const dir = makeTempDir("plannotator-pi-sem-missing-server-"); + process.env.PLANNOTATOR_SEM_PATH = join(dir, "missing-sem"); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startReviewServer({ + rawPatch: semanticRawPatch, + gitRef: "test", + origin: "pi", + htmlContent: "review", + }); + + try { + const diffPayload = await fetch(`${server.url}/api/diff`).then((response) => response.json()) as { + semanticDiff?: { available: boolean }; + }; + expect(diffPayload.semanticDiff).toEqual({ available: false }); + + const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as { + status: string; + reason?: string; + }; + expect(semanticPayload).toMatchObject({ + status: "unavailable", + reason: "sem-path-missing", + }); + } finally { + server.stop(); + } + }); test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); @@ -368,9 +563,13 @@ describe("pi review server", () => { const homeDir = makeTempDir("plannotator-pi-home-"); const root = makeTempDir("plannotator-pi-workspace-"); const apiDir = join(root, "api"); + const semDir = makeTempDir("plannotator-pi-workspace-switch-sem-"); + const cwdLogPath = join(semDir, "cwd-log"); + const inputLogPath = join(semDir, "input.patch"); mkdirSync(apiDir, { recursive: true }); process.env.HOME = homeDir; process.env.PLANNOTATOR_PORT = String(await reservePort()); + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(semDir, { runCwdLogPath: cwdLogPath, inputLogPath }); git(apiDir, ["init"]); git(apiDir, ["branch", "-M", "main"]); @@ -408,22 +607,36 @@ describe("pi review server", () => { agentCwd?: string; diffType?: string; diffOptions?: Array<{ id: string }>; + semanticDiff?: { available: boolean }; }; expect(diffPayload.mode).toBe("workspace"); expect(diffPayload.diffType).toBe("workspace-current"); expect(diffPayload.diffOptions?.map((option) => option.id)).toContain("workspace-last"); expect(diffPayload.agentCwd).toBe(root); + expect(diffPayload.semanticDiff).toEqual(expect.objectContaining({ available: true })); expect("workspace" in diffPayload).toBe(false); + const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as { + status: string; + }; + expect(semanticPayload.status).toBe("ok"); + expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(root)); + expect(readFileSync(inputLogPath, "utf-8")).toContain("diff --git a/api/tracked.txt b/api/tracked.txt"); + const switchResponse = await fetch(`${server.url}/api/diff/switch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diffType: "workspace-last", hideWhitespace: true }), }); expect(switchResponse.status).toBe(200); - const switched = await switchResponse.json() as { diffType?: string; diffOptions?: Array<{ id: string }> }; + const switched = await switchResponse.json() as { + diffType?: string; + diffOptions?: Array<{ id: string }>; + semanticDiff?: { available: boolean }; + }; expect(switched.diffType).toBe("workspace-last"); expect(switched.diffOptions?.map((option) => option.id)).toContain("workspace-current"); + expect(switched.semanticDiff).toEqual(expect.objectContaining({ available: true })); const currentResponse = await fetch(`${server.url}/api/diff/switch`, { method: "POST", diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 2efd4783..c4f1e1b4 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -95,6 +95,16 @@ import { validateCodeNavRequest, extractChangedFiles, } from "../generated/code-nav.js"; +import { + createDefaultSemanticDiffRuntime, + getSemanticDiffAvailability, + getSemanticDiffScratchCwd, + runSemanticDiff, + semanticDiffCacheKey, + semanticDiffFileExtsFromSearchParams, + SemanticDiffResponseCache, +} from "../generated/semantic-diff.js"; +import type { SemanticDiffAvailability, SemanticDiffResponse } from "../generated/semantic-diff-types.js"; import { canStageFiles, detectRemoteDefaultCompareTarget, @@ -301,6 +311,69 @@ export async function startReviewServer(options: { return workspace.getPromptContext(); } const tour = createTourSession(); + const semanticDiffScratchCwd = getSemanticDiffScratchCwd(); + function resolveSemanticDiffCwd(): string { + if (workspace) return workspace.root; + if (options.worktreePool && prMeta) { + const poolPath = options.worktreePool.resolve(prMeta.url); + if (poolPath) return poolPath; + } + if (options.agentCwd) return options.agentCwd; + if (options.gitContext) { + const vcsCwd = resolveVcsCwd(currentDiffType as DiffType, options.gitContext.cwd); + if (vcsCwd) return vcsCwd; + if (options.gitContext.cwd) return options.gitContext.cwd; + } + return semanticDiffScratchCwd; + } + const semanticDiffCache = new SemanticDiffResponseCache(); + const semanticDiffAvailabilityCache = new Map>(); + + function createSemanticDiffRuntime(cwd: string) { + return { + ...createDefaultSemanticDiffRuntime(), + cwd, + }; + } + + function getSemanticDiffAvailabilityForCwd(cwd: string): Promise { + const cached = semanticDiffAvailabilityCache.get(cwd); + if (cached) return cached; + + const next: Promise = getSemanticDiffAvailability(createSemanticDiffRuntime(cwd)).catch((error) => ({ + available: false, + reason: "sem-probe-failed", + message: error instanceof Error ? error.message : String(error), + })); + semanticDiffAvailabilityCache.set(cwd, next); + return next; + } + + async function getSemanticDiffAdvert() { + const availability = await getSemanticDiffAvailabilityForCwd(resolveSemanticDiffCwd()); + return { + available: availability.available, + ...(availability.semVersion ? { semVersion: availability.semVersion } : {}), + ...(availability.semSource ? { semSource: availability.semSource } : {}), + }; + } + + async function getSemanticDiff(url: URL): Promise { + const cwd = resolveSemanticDiffCwd(); + const fileExts = semanticDiffFileExtsFromSearchParams(url.searchParams); + const cacheKey = semanticDiffCacheKey({ rawPatch: currentPatch, cwd, fileExts }); + const cached = semanticDiffCache.get(cacheKey, currentPatch); + if (cached) return cached; + + const result = await runSemanticDiff( + { rawPatch: currentPatch, cwd, fileExts }, + createSemanticDiffRuntime(cwd), + ); + if (result.status === "ok") { + semanticDiffCache.set(cacheKey, currentPatch, result); + } + return result; + } const agentJobs = createAgentJobHandler({ mode: "review", @@ -543,8 +616,11 @@ export async function startReviewServer(options: { }), ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), ...(currentError && { error: currentError }), + semanticDiff: await getSemanticDiffAdvert(), serverConfig: getServerConfig(gitUser), }); + } else if (url.pathname === "/api/semantic-diff" && req.method === "GET") { + json(res, await getSemanticDiff(url)); } else if (url.pathname === "/api/diff/switch" && req.method === "POST") { if (!hasLocalAccess && !workspace) { json(res, { error: "Not available without local file access" }, 400); @@ -578,6 +654,7 @@ export async function startReviewServer(options: { diffOptions: workspace.diffOptions, hideWhitespace: currentHideWhitespace, ...(currentError ? { error: currentError } : {}), + semanticDiff: await getSemanticDiffAdvert(), }); return; } @@ -622,6 +699,7 @@ export async function startReviewServer(options: { hideWhitespace: currentHideWhitespace, ...(updatedContext ? { gitContext: updatedContext } : {}), ...(currentError ? { error: currentError } : {}), + semanticDiff: await getSemanticDiffAdvert(), }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to switch diff"; @@ -649,6 +727,7 @@ export async function startReviewServer(options: { gitRef: currentGitRef, prDiffScope: currentPRDiffScope, ...(currentError ? { error: currentError } : {}), + semanticDiff: await getSemanticDiffAdvert(), }); return; } @@ -675,6 +754,7 @@ export async function startReviewServer(options: { rawPatch: currentPatch, gitRef: currentGitRef, prDiffScope: currentPRDiffScope, + semanticDiff: await getSemanticDiffAdvert(), }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to switch PR diff scope"; @@ -756,6 +836,7 @@ export async function startReviewServer(options: { repoInfo, ...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }), ...(currentError ? { error: currentError } : {}), + semanticDiff: await getSemanticDiffAdvert(), }); } 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 b9759a26..457babe6 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -7,7 +7,7 @@ cd "$(dirname "$0")" rm -rf generated mkdir -p generated generated/ai/providers -for f in feedback-templates prompts review-core diff-paths jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir; do +for f in feedback-templates prompts review-core diff-paths jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown 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; 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/docs/semantic-diff-handoff.md b/docs/semantic-diff-handoff.md new file mode 100644 index 00000000..7a9504b9 --- /dev/null +++ b/docs/semantic-diff-handoff.md @@ -0,0 +1,340 @@ +# Semantic Diff Handoff + +PR: https://github.com/backnotprop/plannotator/pull/871 + +Branch: `feat/sem-diff` + +## What Was Built + +This PR adds a semantic diff overview to the code review UI. Instead of starting reviewers in a raw file list, Plannotator can now show a compact entity-level overview like: + +```txt +packages/ui/components/Settings.tsx + added function isBuiltInFont + modified function ReviewDisplayTab +``` + +The view is backed by the Ataraxy `sem` CLI. Plannotator sends the active review patch to `sem diff --patch --format json`, receives structured JSON, then renders grouped semantic rows in a Dockview panel. + +The semantic diff panel is intended to be the default landing view when sem is available. The existing `All files` panel remains available directly underneath it in the file tree. + +## Main User Flow + +1. The review server creates or updates `currentPatch`. +2. The server chooses a semantic diff cwd: + - workspace root for workspace reviews + - PR worktree/local checkout if present + - local Git/JJ repo cwd when available + - neutral scratch directory for patch-only reviews +3. The server runs `sem diff --patch --format json` with `currentPatch` on stdin. +4. The server parses sem JSON into `SemanticDiffResponse`. +5. The UI fetches `/api/semantic-diff`. +6. The semantic panel renders file groups and entity rows. +7. Clicking a semantic row opens the existing diff file view and selects the relevant line range. + +The important architectural rule is: + +```txt +review mode -> currentPatch -> sem diff --patch -> semantic overview UI +``` + +Local code improves cwd/blob resolution, but patch-only mode still works from hunk content. + +## How It Was Built + +### Shared Sem Runner + +Source: + +- `packages/shared/semantic-diff.ts` +- `packages/shared/semantic-diff-types.ts` +- `packages/shared/semantic-diff.test.ts` + +Responsibilities: + +- Resolve the sem binary. +- Prefer `PLANNOTATOR_SEM_PATH` when explicitly configured. +- Prefer the managed Plannotator sidecar under the user data dir. +- Fall back to PATH only after managed sem. +- Avoid executing repo-local sem packages from reviewed code. +- On Windows, do not spawn bare `sem`; require an absolute resolved `sem.exe`, managed sem, or explicit env path. +- Run `sem diff --patch --format json`. +- Normalize optional `fileExt` and `fileExts` query filters. +- Parse sem JSON into stable Plannotator response types. +- Cache responses by patch, cwd, and file extension filter. + +### Bun Review Server + +Source: + +- `packages/server/review.ts` +- `packages/server/review-workspace.test.ts` + +Responsibilities: + +- Advertise semantic diff availability in `/api/diff` and diff-switch responses. +- Serve parsed semantic diff from `GET /api/semantic-diff`. +- Track the active `currentPatch`. +- Resolve semantic cwd independently from agent cwd. +- Cache sem availability per cwd. +- Cache semantic diff results for the active patch. + +### Pi/Node Review Server Mirror + +Source: + +- `apps/pi-extension/server/serverReview.ts` +- `apps/pi-extension/server.test.ts` +- `apps/pi-extension/vendor.sh` + +Responsibilities: + +- Mirror the Bun server semantic diff behavior. +- Vendor shared semantic diff modules into `apps/pi-extension/generated/`. +- Keep route parity for `/api/semantic-diff`. + +### GitHub and GitLab PR Inputs + +GitHub source: + +- `packages/shared/pr-github.ts` + +GitLab source: + +- `packages/shared/pr-gitlab.ts` +- `packages/shared/pr-gitlab.test.ts` + +Important GitLab note: + +- GitLab now uses `raw_diffs`, not reconstructed JSON diffs. +- This preserves collapsed/generated file content and binary diff markers. +- Public GitLab fixture testing showed JSON reconstruction missed binary changes and could omit collapsed generated files. + +## Frontend Asset Map + +### Review App Entrypoints + +- `apps/review/index.html` +- `apps/review/index.tsx` +- `apps/review/vite.config.ts` +- `apps/review/dist/index.html` after build + +The review app imports the review editor package and builds a single-file review UI. + +### Hook App Bundled Review HTML + +- `apps/hook/index.html` +- `apps/hook/index.tsx` +- `apps/hook/vite.config.ts` +- `apps/hook/dist/review.html` after build + +`apps/hook/dist/review.html` is the bundled review editor HTML copied from the review build during hook build. + +### Main Review UI Wiring + +- `packages/review-editor/App.tsx` + +Key responsibilities: + +- Tracks `semanticDiffAvailable`. +- Opens semantic diff as the initial default panel when advertised. +- Falls back to `All files` if initial semantic load errors. +- Applies semantic diff availability updates after diff switches and PR switches. +- Passes semantic diff state and handlers through `ReviewStateContext`. + +### Semantic Diff Panel + +- `packages/review-editor/dock/panels/ReviewSemanticDiffPanel.tsx` + +Key responsibilities: + +- Fetches `GET /api/semantic-diff`. +- Handles loading, empty, error, unavailable, and ready states. +- Groups semantic changes by file path. +- Renders the terminal-like semantic diff rows. +- Opens the existing diff file panel when a row is clicked. +- Converts semantic line metadata into Plannotator line selection. + +### Dockview Registration + +- `packages/review-editor/dock/reviewPanelTypes.ts` +- `packages/review-editor/dock/reviewPanelComponents.ts` +- `packages/review-editor/dock/ReviewStateContext.tsx` + +Key responsibilities: + +- Defines `REVIEW_PANEL_TYPES.SEMANTIC_DIFF`. +- Defines `REVIEW_SEMANTIC_DIFF_PANEL_ID`. +- Registers `ReviewSemanticDiffPanel` as a Dockview component. +- Exposes semantic diff state and callbacks to panel components. + +### Left File Tree Entry + +- `packages/review-editor/components/FileTree.tsx` + +Key responsibilities: + +- Shows the `Semantic diff` navigation entry above `All files`. +- Only shows it when `semanticDiffAvailable` is true. +- Keeps file selection inactive while semantic diff or all-files overview is active. + +### PR Switch Hook Type Wiring + +- `packages/review-editor/hooks/usePRStack.ts` + +Key responsibilities: + +- Carries semantic diff advert data through PR scope/switch responses. +- Uses shared `SemanticDiffAdvert` from `@plannotator/shared/semantic-diff-types`. + +### Semantic Diff Styles + +- `packages/review-editor/index.css` + +Relevant CSS block: + +- `.semantic-diff-panel` +- `.semantic-diff-terminal` +- `.semantic-diff-file` +- `.semantic-diff-row` +- `.semantic-diff-symbol-*` +- `.semantic-diff-summary` +- `.semantic-diff-retry` +- `.semantic-diff-loading` +- `.semantic-diff-error` +- `.semantic-diff-empty` + +The styling intentionally mirrors sem's terminal output shape: file boxes, pipe glyphs, change symbols, entity type, entity name, and status label. + +## API Surface + +### `GET /api/diff` + +Now includes: + +```ts +semanticDiff?: { + available: boolean; + semVersion?: string; + semSource?: string; +} +``` + +### `GET /api/semantic-diff` + +Returns one of: + +- `SemanticDiffOkResponse` +- `SemanticDiffUnavailableResponse` +- `SemanticDiffErrorResponse` + +Optional query filters: + +- `?fileExt=.ts` +- `?fileExts=.ts,.tsx` + +### Diff Switch and PR Switch Responses + +These responses also include the semantic diff advert when available: + +- `/api/diff/switch` +- `/api/pr-diff-scope` +- `/api/pr-switch` + +## Install And Binary Behavior + +Installer changes install sem as an optional sidecar dependency. Semantic diff is non-fatal: + +- If sem is available, the semantic diff UI can open. +- If sem is unavailable, Plannotator hides/falls back to the existing all-files diff path. +- If sem errors during initial auto-open, the UI falls back to `All files`. + +Relevant installer files: + +- `scripts/install.sh` +- `scripts/install.ps1` +- `scripts/install.cmd` + +Managed sem path: + +```txt +/vendor/sem//sem +/vendor/sem//sem.exe +``` + +## Review Modes Covered + +### Local Git/JJ + +Uses the local repo cwd and active VCS patch. + +### Workspace / Multi-Repo + +Aggregates child repo patches with workspace-prefixed paths, then runs sem from the workspace root. Sem can fall back to hunk content when child repo blob SHAs are not resolvable from the parent. + +### GitHub PR + +Uses `gh pr diff` patch text. If local checkout/worktree exists, semantic diff runs from that cwd. Otherwise it runs from scratch. + +### GitLab MR + +Uses GitLab `raw_diffs` via `glab api`, preserving raw unified patch text and binary markers. + +### PR Full-Stack + +Requires local checkout/worktree to create the full-stack patch. Once `currentPatch` exists, semantic diff uses the same pipeline. + +### Raw / Shared / Patch-Only + +Runs sem from a neutral scratch cwd with patch text on stdin. + +## Verification Run + +Latest local verification after the final hardening pass: + +```sh +bun test +# 1357 pass, 0 fail + +bunx tsc --noEmit -p packages/shared/tsconfig.json +bunx tsc --noEmit -p packages/server/tsconfig.json +bunx tsc --noEmit -p packages/ui/tsconfig.json +bunx tsc --noEmit -p apps/pi-extension/tsconfig.json + +bun run build:pi + +git diff --check +``` + +All passed. + +## Useful Public GitLab Fixtures + +These were used to validate GitLab patch behavior without running the full app: + +- Normal TypeScript: `https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/merge_requests/3226` +- Renamed TypeScript: `https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/merge_requests/3220` +- Rename plus new TypeScript: `https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/merge_requests/3059` +- TS plus Vue: `https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/merge_requests/3176` +- Binary marker: `https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4794` +- Binary add/delete: `https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4448` + +## Current Known Tradeoffs + +- Workspace semantic diff runs once against the aggregated workspace patch. This is simple and works for normal hunk-backed changes, but a future precision improvement could run sem per child repo and merge/prefix the semantic results. +- Checkout-less PR and raw-patch reviews rely on hunk content rather than local blob resolution. This is expected and still useful. +- Sem language coverage is controlled by sem itself. Unsupported file extensions can still fall back to chunk-style behavior inside sem. + +## Final PR State At Handoff + +PR: + +```txt +https://github.com/backnotprop/plannotator/pull/871 +``` + +Implementation head before this handoff doc was added: + +```txt +13d44b02d6cf6c1fd050cbf552bd494f2a729e5a +``` diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index c7c87337..8675fa53 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -14,6 +14,7 @@ import { GitLabIcon } from '@plannotator/ui/components/GitLabIcon'; 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 { configStore, useConfigValue } from '@plannotator/ui/config'; import { loadDiffFont } from '@plannotator/ui/utils/diffFonts'; import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; @@ -73,6 +74,7 @@ import { REVIEW_PR_SUMMARY_PANEL_ID, REVIEW_PR_COMMENTS_PANEL_ID, REVIEW_PR_CHECKS_PANEL_ID, + REVIEW_SEMANTIC_DIFF_PANEL_ID, REVIEW_ALL_FILES_PANEL_ID, REVIEW_CODE_NAV_PANEL_ID, } from './dock/reviewPanelTypes'; @@ -98,6 +100,7 @@ interface DiffData { prStackInfo?: PRStackInfo | null; prDiffScope?: PRDiffScope; prDiffScopeOptions?: PRDiffScopeOption[]; + semanticDiff?: SemanticDiffAdvert; } function getFileTabTitle(filePath: string): string { @@ -112,6 +115,8 @@ const ReviewApp: React.FC = () => { const [annotations, setAnnotations] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [isAllFilesActive, setIsAllFilesActive] = useState(false); + const [isSemanticDiffActive, setIsSemanticDiffActive] = useState(false); + const [semanticDiffAvailable, setSemanticDiffAvailable] = useState(false); const [isDiffPanelActive, setIsDiffPanelActive] = useState(false); const [allFilesVisibleFile, setAllFilesVisibleFile] = useState(null); const [pendingSelection, setPendingSelection] = useState(null); @@ -272,6 +277,7 @@ const ReviewApp: React.FC = () => { const filesRef = useRef(files); filesRef.current = files; const needsInitialDiffPanel = useRef(true); + const semanticDiffAutoFallbackPending = useRef(false); // PR context (lifted from sidebar so center dock PR panels can access it) const { prContext, isLoading: isPRContextLoading, error: prContextError, fetchContext: fetchPRContext } = usePRContext(prMetadata ?? null); @@ -281,6 +287,7 @@ const ReviewApp: React.FC = () => { const openDiffFile = useCallback((filePath: string) => { const file = files.find(candidate => candidate.path === filePath); if (!file) return; + semanticDiffAutoFallbackPending.current = false; if (!dockApi) { const fileIndex = files.findIndex(candidate => candidate.path === filePath); @@ -444,7 +451,9 @@ const ReviewApp: React.FC = () => { existing.api.setTitle(`References: ${request.symbol}`); existing.api.setActive(); } else { - const refPanel = isAllFilesActive + const refPanel = isSemanticDiffActive + ? REVIEW_SEMANTIC_DIFF_PANEL_ID + : isAllFilesActive ? REVIEW_ALL_FILES_PANEL_ID : REVIEW_DIFF_PANEL_ID; dockApi.addPanel({ @@ -455,7 +464,7 @@ const ReviewApp: React.FC = () => { initialHeight: 250, }); } - }, [codeNav.resolve, dockApi, isAllFilesActive, gitContext, agentCwd]); + }, [codeNav.resolve, dockApi, isAllFilesActive, isSemanticDiffActive, gitContext, agentCwd]); // Check AI capabilities on mount useEffect(() => { @@ -594,8 +603,14 @@ const ReviewApp: React.FC = () => { // Sync activeFileIndex when user switches between dock tabs event.api.onDidActivePanelChange((panel) => { - if (!panel) { setIsAllFilesActive(false); setIsDiffPanelActive(false); return; } + if (!panel) { + setIsAllFilesActive(false); + setIsSemanticDiffActive(false); + setIsDiffPanelActive(false); + return; + } setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID); + setIsSemanticDiffActive(panel.id === REVIEW_SEMANTIC_DIFF_PANEL_ID); setIsDiffPanelActive(isReviewDiffPanelId(panel.id)); if (!isReviewDiffPanelId(panel.id)) return; const filePath = getReviewDiffPanelFilePath(panel.params); @@ -614,7 +629,10 @@ const ReviewApp: React.FC = () => { event.api.totalPanels === 1 && event.api.groups.length === 1 ? event.api.groups[0]?.panels[0] : undefined; - const hideHeaders = lonePanel?.id === REVIEW_DIFF_PANEL_ID || lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID; + const hideHeaders = + lonePanel?.id === REVIEW_DIFF_PANEL_ID || + lonePanel?.id === REVIEW_SEMANTIC_DIFF_PANEL_ID || + lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID; for (const group of event.api.groups) { group.header.hidden = hideHeaders; } @@ -703,6 +721,7 @@ const ReviewApp: React.FC = () => { const openAllFilesPanel = useCallback(() => { if (!dockApi) return; + semanticDiffAutoFallbackPending.current = false; const existing = dockApi.getPanel(REVIEW_ALL_FILES_PANEL_ID); if (existing) { existing.api.setActive(); return; } dockApi.addPanel({ @@ -712,7 +731,59 @@ const ReviewApp: React.FC = () => { }); }, [dockApi]); - // Open the all-files panel on first load. + const openSemanticDiffPanel = useCallback((options?: { autoFallbackOnError?: boolean }) => { + if (!dockApi) return; + semanticDiffAutoFallbackPending.current = options?.autoFallbackOnError === true; + if (!semanticDiffAvailable) { + openAllFilesPanel(); + return; + } + const existing = dockApi.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID); + if (existing) { existing.api.setActive(); return; } + dockApi.addPanel({ + id: REVIEW_SEMANTIC_DIFF_PANEL_ID, + component: REVIEW_PANEL_TYPES.SEMANTIC_DIFF, + title: 'Semantic diff', + }); + }, [dockApi, openAllFilesPanel, semanticDiffAvailable]); + + const handleSemanticDiffUnavailable = useCallback(() => { + semanticDiffAutoFallbackPending.current = false; + setSemanticDiffAvailable(false); + dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close(); + openAllFilesPanel(); + }, [dockApi, openAllFilesPanel]); + + const handleSemanticDiffLoadSuccess = useCallback(() => { + semanticDiffAutoFallbackPending.current = false; + }, []); + + const handleSemanticDiffLoadError = useCallback(() => { + if (!semanticDiffAutoFallbackPending.current) return false; + if (dockApi?.activePanel?.id !== REVIEW_SEMANTIC_DIFF_PANEL_ID) { + // The user has already moved on; don't steal focus by auto-opening All files. + semanticDiffAutoFallbackPending.current = false; + return false; + } + semanticDiffAutoFallbackPending.current = false; + dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close(); + openAllFilesPanel(); + return true; + }, [dockApi, openAllFilesPanel]); + + const applySemanticDiffAdvert = useCallback((semanticDiff?: SemanticDiffAdvert) => { + if (!semanticDiff) return; + const available = semanticDiff.available === true; + setSemanticDiffAvailable(available); + if (!available) { + semanticDiffAutoFallbackPending.current = false; + dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close(); + if (isSemanticDiffActive) openAllFilesPanel(); + } + }, [dockApi, isSemanticDiffActive, 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(() => { if (!dockApi || !needsInitialDiffPanel.current || files.length === 0) return; needsInitialDiffPanel.current = false; @@ -802,6 +873,7 @@ const ReviewApp: React.FC = () => { viewedFiles?: string[]; error?: string; isWSL?: boolean; + semanticDiff?: SemanticDiffAdvert; serverConfig?: { displayName?: string; gitUser?: string }; }) => { // Initialize config store with server-provided values (config file > cookie > default) @@ -850,6 +922,7 @@ const ReviewApp: React.FC = () => { } if (data.error) setDiffError(data.error); if (data.isWSL) setIsWSL(true); + setSemanticDiffAvailable(data.semanticDiff?.available === true); // Mark diff type setup as pending on first run (local mode only) if (data.diffType && data.mode !== 'workspace' && !data.prMetadata && data.gitContext && data.gitContext.vcsType !== 'p4' && data.gitContext.vcsType !== 'jj' && needsDiffTypeSetup()) { setDiffTypeSetupPending(true); @@ -865,6 +938,7 @@ const ReviewApp: React.FC = () => { }); setFiles(demoFiles); setWorkspaceDiffOptions(null); + setSemanticDiffAvailable(false); }) .finally(() => setIsLoading(false)); }, []); @@ -1124,6 +1198,7 @@ const ReviewApp: React.FC = () => { rawPatch: string; gitRef: string; repoInfo?: { display: string; branch?: string }; viewedFiles?: string[]; error?: string; + semanticDiff?: SemanticDiffAdvert; }) { const isPRSwitch = !!data.prMetadata; const nextFiles = parseDiffToFiles(data.rawPatch); @@ -1151,6 +1226,7 @@ const ReviewApp: React.FC = () => { setViewedFiles(data.viewedFiles ? new Set(data.viewedFiles) : new Set()); } setDiffError(data.error || null); + applySemanticDiffAdvert(data.semanticDiff); resetStagedFiles(); } @@ -1187,9 +1263,11 @@ const ReviewApp: React.FC = () => { gitContext?: GitContext; diffOptions?: DiffOption[]; error?: string; + semanticDiff?: SemanticDiffAdvert; }; const nextFiles = parseDiffToFiles(data.rawPatch); + applySemanticDiffAdvert(data.semanticDiff); if (options?.preserveFile) { // Whitespace toggle: update patch in-place, keep the active file. @@ -1257,7 +1335,7 @@ const ReviewApp: React.FC = () => { } finally { setIsLoadingDiff(false); } - }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile]); + }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile, applySemanticDiffAdvert]); // Switch the base branch the current diff compares against. // Only triggers a refetch when the active mode actually uses a base. @@ -1380,6 +1458,7 @@ const ReviewApp: React.FC = () => { // Build ReviewState value for dock panel context const reviewStateValue = useMemo(() => ({ files, + rawPatch: diffData?.rawPatch ?? '', focusedFileIndex: activeFileIndex, focusedFilePath: files[activeFileIndex]?.path ?? null, diffStyle, @@ -1446,13 +1525,18 @@ const ReviewApp: React.FC = () => { openDiffFile, onAllFilesVisibleFileChange: setAllFilesVisibleFile, isAllFilesActive, + isSemanticDiffActive, + semanticDiffAvailable, + onSemanticDiffUnavailable: handleSemanticDiffUnavailable, + onSemanticDiffLoadError: handleSemanticDiffLoadError, + onSemanticDiffLoadSuccess: handleSemanticDiffLoadSuccess, openTourPanel: handleOpenTour, onCodeNavRequest: handleCodeNavRequest, codeNavResult: codeNav.result, codeNavIsLoading: codeNav.isLoading, codeNavActiveSymbol: codeNav.activeSymbol, }), [ - files, activeFileIndex, diffStyle, diffOverflow, diffIndicators, + files, diffData?.rawPatch, activeFileIndex, diffStyle, diffOverflow, diffIndicators, diffLineDiffType, diffShowLineNumbers, diffShowBackground, diffExpandUnchanged, diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, allAnnotations, externalAnnotations, @@ -1466,7 +1550,8 @@ const ReviewApp: React.FC = () => { handleAskAI, handleViewAIResponse, handleClickAIMarker, aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext, isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile, - handleOpenTour, isAllFilesActive, handleAddAnnotationForFile, + handleOpenTour, isAllFilesActive, isSemanticDiffActive, semanticDiffAvailable, + handleSemanticDiffUnavailable, handleSemanticDiffLoadError, handleSemanticDiffLoadSuccess, handleAddAnnotationForFile, handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol, ]); @@ -2159,6 +2244,9 @@ const ReviewApp: React.FC = () => { openSemanticDiffPanel()} + isSemanticDiffActive={isSemanticDiffActive} + semanticDiffAvailable={semanticDiffAvailable} onSelectAllFiles={openAllFilesPanel} isAllFilesActive={isAllFilesActive} scrollHighlightIndex={isAllFilesActive && allFilesVisibleFile ? files.findIndex(f => f.path === allFilesVisibleFile) : undefined} diff --git a/packages/review-editor/components/DiffViewer.tsx b/packages/review-editor/components/DiffViewer.tsx index 7be8cd37..1b2deb3f 100644 --- a/packages/review-editor/components/DiffViewer.tsx +++ b/packages/review-editor/components/DiffViewer.tsx @@ -429,6 +429,47 @@ export const DiffViewer: React.FC = ({ return retryScrollToSearchMatch(containerRef.current, activeSearchMatch); }, [activeSearchMatch, filePath, diffStyle, diffOverflow, diffIndicators, lineDiffType, disableLineNumbers, disableBackground, expandUnchanged, viewport]); + // Scroll to the selected line range — drives "jump to entity" from semantic-diff + // clicks and AI "scroll to lines". Mirrors the scroll-to-annotation behavior used + // by sidebar comments (center the target, smooth). pierre tags the selected rows + // with `[data-selected-line]` inside the diff shadow DOM once it applies + // `selectedLines`, so we retry across frames until it appears. + // + // Only scroll when the target is off-screen: a manual drag-select also sets + // pendingSelection, but its lines are by definition already visible, so we leave + // the view untouched and avoid yanking it on every selection. + useEffect(() => { + if (!pendingSelection || !containerRef.current) return; + const container = containerRef.current; + let cancelled = false; + let attempts = 0; + const MAX_ATTEMPTS = 30; + + const tryScroll = () => { + if (cancelled) return; + const target = getSearchRoots(container) + .map((root) => (root as ParentNode).querySelector?.('[data-selected-line]') ?? null) + .find((el): el is Element => el != null); + if (target) { + const targetRect = target.getBoundingClientRect(); + const viewRect = container.getBoundingClientRect(); + const fullyVisible = targetRect.top >= viewRect.top && targetRect.bottom <= viewRect.bottom; + if (!fullyVisible) { + target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); + } + return; + } + attempts += 1; + if (attempts < MAX_ATTEMPTS) requestAnimationFrame(tryScroll); + }; + + const raf = requestAnimationFrame(tryScroll); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + }; + }, [pendingSelection, filePath, augmentedDiff, viewport]); + // Map annotations to @pierre/diffs format const lineAnnotations = useMemo(() => { return annotations diff --git a/packages/review-editor/components/FileHeader.tsx b/packages/review-editor/components/FileHeader.tsx index f456be0e..1134ca17 100644 --- a/packages/review-editor/components/FileHeader.tsx +++ b/packages/review-editor/components/FileHeader.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { DiffOptionsPopover } from './DiffOptionsPopover'; +import { SemanticFileBadge } from './SemanticFileBadge'; interface FileHeaderProps { filePath: string; @@ -202,6 +203,7 @@ export const FileHeader: React.FC = ({ )} + diff --git a/packages/review-editor/components/FileTree.tsx b/packages/review-editor/components/FileTree.tsx index f5c3d602..c503f44d 100644 --- a/packages/review-editor/components/FileTree.tsx +++ b/packages/review-editor/components/FileTree.tsx @@ -60,6 +60,9 @@ interface FileTreeProps { activeSearchMatchId?: string | null; onSelectSearchMatch?: (matchId: string) => void; onStepSearchMatch?: (direction: 1 | -1) => void; + onSelectSemanticDiff?: () => void; + isSemanticDiffActive?: boolean; + semanticDiffAvailable?: boolean; onSelectAllFiles?: () => void; isAllFilesActive?: boolean; scrollHighlightIndex?: number; @@ -112,6 +115,9 @@ export const FileTree: React.FC = ({ activeSearchMatchId, onSelectSearchMatch, onStepSearchMatch, + onSelectSemanticDiff, + isSemanticDiffActive = false, + semanticDiffAvailable = false, onSelectAllFiles, isAllFilesActive = false, scrollHighlightIndex, @@ -452,6 +458,19 @@ export const FileTree: React.FC = ({ ) ) : ( <> + {semanticDiffAvailable && onSelectSemanticDiff && ( + + )} {onSelectAllFiles && ( + + + e.preventDefault()} + onMouseEnter={cancelClose} + onMouseLeave={scheduleClose} + > +
+ sem + {filePath} +
+
+ +
+
+
+ + ); +}; diff --git a/packages/review-editor/dock/ReviewStateContext.tsx b/packages/review-editor/dock/ReviewStateContext.tsx index 146c3ae3..7424c731 100644 --- a/packages/review-editor/dock/ReviewStateContext.tsx +++ b/packages/review-editor/dock/ReviewStateContext.tsx @@ -18,6 +18,7 @@ import type { FeedbackDiffContext } from '../utils/exportFeedback'; export interface ReviewState { // Files & diff files: DiffFile[]; + rawPatch: string; focusedFileIndex: number; focusedFilePath: string | null; diffStyle: 'split' | 'unified'; @@ -97,6 +98,11 @@ export interface ReviewState { openDiffFile: (filePath: string) => void; onAllFilesVisibleFileChange: (filePath: string | null) => void; isAllFilesActive: boolean; + isSemanticDiffActive: boolean; + semanticDiffAvailable: boolean; + onSemanticDiffUnavailable: () => void; + onSemanticDiffLoadError: () => boolean; + onSemanticDiffLoadSuccess: () => void; // Tour openTourPanel: (jobId: string) => void; @@ -129,3 +135,8 @@ export function useReviewState(): ReviewState { if (!ctx) throw new Error('useReviewState must be used within ReviewStateProvider'); return ctx; } + +/** Like useReviewState but returns null instead of throwing — for components that may render outside the provider. */ +export function useReviewStateOptional(): ReviewState | null { + return useContext(ReviewStateContext); +} diff --git a/packages/review-editor/dock/panels/ReviewSemanticDiffPanel.tsx b/packages/review-editor/dock/panels/ReviewSemanticDiffPanel.tsx new file mode 100644 index 00000000..e5f16344 --- /dev/null +++ b/packages/review-editor/dock/panels/ReviewSemanticDiffPanel.tsx @@ -0,0 +1,188 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { + SemanticDiffBinaryChange, + SemanticDiffChange, + SemanticDiffResponse, +} from '@plannotator/shared/semantic-diff-types'; +import { useReviewState } from '../ReviewStateContext'; +import { + SemanticDiffRows, + groupSemanticChangesByFile, + lineSelectionForChange, +} from './semanticDiffShared'; + +type SemanticDiffOkResponse = Extract; +type SemanticDiffErrorResponse = Extract; + +type LoadState = + | { status: 'idle' | 'loading' } + | { status: 'ready'; data: SemanticDiffOkResponse } + | { status: 'empty'; data: SemanticDiffOkResponse } + | { status: 'error'; error: SemanticDiffErrorResponse | Error }; + +function formatSummary(data: SemanticDiffOkResponse): string { + const summary = data.summary; + const parts = [ + `${summary.added} added`, + `${summary.modified} modified`, + `${summary.deleted} deleted`, + ]; + if (summary.renamed > 0) parts.push(`${summary.renamed} renamed`); + if (summary.moved > 0) parts.push(`${summary.moved} moved`); + if (summary.reordered > 0) parts.push(`${summary.reordered} reordered`); + if (summary.binary > 0) parts.push(`${summary.binary} binary`); + if (summary.orphan > 0) parts.push(`${summary.orphan} orphans`); + return `Summary: ${parts.join(', ')} across ${summary.fileCount} files`; +} + +function formatLoadError(error: SemanticDiffErrorResponse | Error): string { + return error.message || 'Semantic diff failed.'; +} + +function splitFilePath(filePath: string): { dir: string; name: string } { + const lastSlash = filePath.lastIndexOf('/'); + if (lastSlash === -1) return { dir: '', name: filePath }; + return { dir: filePath.slice(0, lastSlash + 1), name: filePath.slice(lastSlash + 1) }; +} + +export function ReviewSemanticDiffPanel() { + const state = useReviewState(); + const { + rawPatch, + semanticDiffAvailable, + onSemanticDiffUnavailable, + onSemanticDiffLoadError, + onSemanticDiffLoadSuccess, + openDiffFile, + onLineSelection, + } = state; + const [loadState, setLoadState] = useState({ status: 'idle' }); + const [retryCount, setRetryCount] = useState(0); + + useEffect(() => { + if (!semanticDiffAvailable) return; + + const controller = new AbortController(); + setLoadState({ status: 'loading' }); + + fetch('/api/semantic-diff', { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error('Semantic diff failed'); + return res.json() as Promise; + }) + .then((data) => { + if (controller.signal.aborted) return; + if (data.status === 'unavailable') { + onSemanticDiffUnavailable(); + return; + } + if (data.status === 'error') { + if (onSemanticDiffLoadError()) return; + setLoadState({ status: 'error', error: data }); + return; + } + onSemanticDiffLoadSuccess(); + setLoadState(data.changes.length === 0 && data.binaryChanges.length === 0 + ? { status: 'empty', data } + : { status: 'ready', data }); + }) + .catch((error) => { + if (controller.signal.aborted) return; + console.error('Failed to load semantic diff:', error); + if (onSemanticDiffLoadError()) return; + setLoadState({ status: 'error', error: error instanceof Error ? error : new Error(String(error)) }); + }); + + return () => controller.abort(); + }, [ + rawPatch, + retryCount, + semanticDiffAvailable, + onSemanticDiffUnavailable, + onSemanticDiffLoadError, + onSemanticDiffLoadSuccess, + ]); + + const groupedChanges = useMemo(() => { + if (loadState.status !== 'ready' && loadState.status !== 'empty') return []; + return groupSemanticChangesByFile(loadState.data.changes, loadState.data.binaryChanges); + }, [loadState]); + + const openChange = useCallback((change: SemanticDiffChange) => { + openDiffFile(change.filePath); + onLineSelection(lineSelectionForChange(change)); + }, [openDiffFile, onLineSelection]); + + const openBinaryChange = useCallback((change: SemanticDiffBinaryChange) => { + openDiffFile(change.filePath); + onLineSelection(null); + }, [openDiffFile, onLineSelection]); + + if (!semanticDiffAvailable) return null; + + if (loadState.status === 'idle' || loadState.status === 'loading') { + return ( +
+
+
Running semantic diff...
+
+
+ ); + } + + if (loadState.status === 'error') { + return ( +
+
+
+ Semantic diff failed: {formatLoadError(loadState.error)} +
+ +
+
+ ); + } + + return ( +
+
+ {groupedChanges.map((group) => ( +
+
+ + {(() => { + const { dir, name } = splitFilePath(group.filePath); + return ( + <> + {dir && {dir}} + {name} + + ); + })()} + +
+
+ +
+
+ ))} + + {loadState.status === 'empty' && ( +
No semantic changes found.
+ )} +
{formatSummary(loadState.data)}
+
+
+ ); +} diff --git a/packages/review-editor/dock/panels/semanticDiffShared.tsx b/packages/review-editor/dock/panels/semanticDiffShared.tsx new file mode 100644 index 00000000..7484f7a3 --- /dev/null +++ b/packages/review-editor/dock/panels/semanticDiffShared.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import type { SelectedLineRange } from '@plannotator/ui/types'; +import type { + SemanticDiffBinaryChange, + SemanticDiffChange, +} from '@plannotator/shared/semantic-diff-types'; + +// `renamed`/`moved` are handled by the early return in getChangeSymbol, so they +// intentionally have no entry here. +const changeSymbols: Record = { + added: '⊕', + deleted: '⊖', + modified: '∆', + reordered: '↕', +}; + +export function getChangeSymbol(changeType: string): string { + if (changeType.includes('renamed') || changeType.includes('moved')) return '↻'; + return changeSymbols[changeType] ?? '∆'; +} + +export function getChangeClass(changeType: string): string { + if (changeType.includes('added')) return 'added'; + if (changeType.includes('deleted')) return 'deleted'; + if (changeType.includes('renamed')) return 'renamed'; + if (changeType.includes('moved')) return 'moved'; + if (changeType.includes('reordered')) return 'reordered'; + return 'modified'; +} + +export function getDisplayName(change: SemanticDiffChange): string { + if (change.oldEntityName && change.oldEntityName !== change.entityName) { + return `${change.oldEntityName} -> ${change.entityName}`; + } + return change.entityName; +} + +export function getBinaryDisplayName(change: SemanticDiffBinaryChange): string { + if (change.oldFilePath && change.oldFilePath !== change.filePath) { + return `${change.oldFilePath} -> ${change.filePath}`; + } + return 'file'; +} + +export function getBinaryStatus(change: SemanticDiffBinaryChange): string { + return change.fileStatus || change.changeType; +} + +export function lineSelectionForChange(change: SemanticDiffChange): SelectedLineRange | null { + const deleted = change.changeType === 'deleted'; + const start = deleted ? change.oldStartLine : change.startLine; + const end = deleted ? change.oldEndLine : change.endLine; + if (!start || start < 1) return null; + + return { + start, + end: end && end >= start ? end : start, + side: deleted ? 'deletions' : 'additions', + }; +} + +/** + * Orphan = module-level change sem couldn't attach to a named entity. Sem hides + * these by default (only the summary count surfaces them); we do the same. + */ +export function isOrphanChange(change: SemanticDiffChange): boolean { + return change.entityType === 'orphan'; +} + +export interface SemanticDiffGroup { + filePath: string; + changes: SemanticDiffChange[]; + binaryChanges: SemanticDiffBinaryChange[]; +} + +/** Group changes by file, preserving order and dropping orphan (module-level) rows. */ +export function groupSemanticChangesByFile( + changes: SemanticDiffChange[], + binaryChanges: SemanticDiffBinaryChange[], +): SemanticDiffGroup[] { + const groups: SemanticDiffGroup[] = []; + const byPath = new Map(); + const getGroup = (filePath: string) => { + const existing = byPath.get(filePath); + if (existing) return existing; + + const next: SemanticDiffGroup = { filePath, changes: [], binaryChanges: [] }; + byPath.set(filePath, next); + groups.push(next); + return next; + }; + + for (const change of changes) { + if (isOrphanChange(change)) continue; + getGroup(change.filePath).changes.push(change); + } + for (const change of binaryChanges) { + getGroup(change.filePath).binaryChanges.push(change); + } + + return groups; +} + +/** The terminal-style entity rows, shared by the semantic panel and the file-header popover. */ +export function SemanticDiffRows({ + changes, + binaryChanges, + onOpenChange, + onOpenBinary, +}: { + changes: SemanticDiffChange[]; + binaryChanges: SemanticDiffBinaryChange[]; + onOpenChange: (change: SemanticDiffChange) => void; + onOpenBinary: (change: SemanticDiffBinaryChange) => void; +}) { + return ( + <> + {changes.map((change, index) => ( + + ))} + {binaryChanges.map((change, index) => { + const status = getBinaryStatus(change); + return ( + + ); + })} + + ); +} diff --git a/packages/review-editor/dock/reviewPanelComponents.ts b/packages/review-editor/dock/reviewPanelComponents.ts index 8e5799eb..3f46d87d 100644 --- a/packages/review-editor/dock/reviewPanelComponents.ts +++ b/packages/review-editor/dock/reviewPanelComponents.ts @@ -4,6 +4,7 @@ import { ReviewAgentJobDetailPanel } from './panels/ReviewAgentJobDetailPanel'; import { ReviewPRSummaryPanel } from './panels/ReviewPRSummaryPanel'; import { ReviewPRCommentsPanel } from './panels/ReviewPRCommentsPanel'; import { ReviewPRChecksPanel } from './panels/ReviewPRChecksPanel'; +import { ReviewSemanticDiffPanel } from './panels/ReviewSemanticDiffPanel'; import { ReviewAllFilesDiffPanel } from './panels/ReviewAllFilesDiffPanel'; import { ReviewCodeNavPanel } from './panels/ReviewCodeNavPanel'; @@ -17,6 +18,7 @@ export const reviewPanelComponents = { [REVIEW_PANEL_TYPES.PR_SUMMARY]: ReviewPRSummaryPanel, [REVIEW_PANEL_TYPES.PR_COMMENTS]: ReviewPRCommentsPanel, [REVIEW_PANEL_TYPES.PR_CHECKS]: ReviewPRChecksPanel, + [REVIEW_PANEL_TYPES.SEMANTIC_DIFF]: ReviewSemanticDiffPanel, [REVIEW_PANEL_TYPES.ALL_FILES]: ReviewAllFilesDiffPanel, [REVIEW_PANEL_TYPES.CODE_NAV]: ReviewCodeNavPanel, } as const; diff --git a/packages/review-editor/dock/reviewPanelTypes.ts b/packages/review-editor/dock/reviewPanelTypes.ts index 67ff72d9..8cd89e0e 100644 --- a/packages/review-editor/dock/reviewPanelTypes.ts +++ b/packages/review-editor/dock/reviewPanelTypes.ts @@ -11,6 +11,7 @@ export const REVIEW_PANEL_TYPES = { PR_SUMMARY: 'review-pr-summary', PR_COMMENTS: 'review-pr-comments', PR_CHECKS: 'review-pr-checks', + SEMANTIC_DIFF: 'review-semantic-diff', ALL_FILES: 'review-all-files', CODE_NAV: 'review-code-nav', } as const; @@ -27,6 +28,7 @@ export const makeReviewAgentJobPanelId = (jobId: string) => export const REVIEW_PR_SUMMARY_PANEL_ID = 'review-pr-summary'; export const REVIEW_PR_COMMENTS_PANEL_ID = 'review-pr-comments'; export const REVIEW_PR_CHECKS_PANEL_ID = 'review-pr-checks'; +export const REVIEW_SEMANTIC_DIFF_PANEL_ID = 'review-semantic-diff'; export const REVIEW_ALL_FILES_PANEL_ID = 'review-all-files'; export const REVIEW_CODE_NAV_PANEL_ID = 'review-code-nav'; diff --git a/packages/review-editor/hooks/useFileSemanticChanges.ts b/packages/review-editor/hooks/useFileSemanticChanges.ts new file mode 100644 index 00000000..6acb84de --- /dev/null +++ b/packages/review-editor/hooks/useFileSemanticChanges.ts @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react'; +import type { + SemanticDiffBinaryChange, + SemanticDiffChange, + SemanticDiffResponse, +} from '@plannotator/shared/semantic-diff-types'; +import { isOrphanChange } from '../dock/panels/semanticDiffShared'; + +/** + * Single shared fetch of the semantic diff, cached by the active patch so every + * file-header badge reuses one request (sem already caches per-patch server-side). + */ +let cacheKey: string | null = null; +let cachePromise: Promise | null = null; + +function loadSemanticDiff(rawPatch: string): Promise { + if (cacheKey === rawPatch && cachePromise) return cachePromise; + cacheKey = rawPatch; + cachePromise = fetch('/api/semantic-diff') + .then((res) => { + if (!res.ok) throw new Error('Semantic diff failed'); + return res.json() as Promise; + }) + .catch((error): SemanticDiffResponse => ({ + status: 'error', + reason: 'fetch-failed', + message: error instanceof Error ? error.message : String(error), + })) + .then((data) => { + // Logged once per patch (the promise is cached) rather than per badge, so a + // systemic failure leaves a trace instead of every badge vanishing silently. + if (data.status !== 'ok') { + console.error('Failed to load semantic diff for file badges:', data.message ?? data.reason ?? data.status); + } + return data; + }); + return cachePromise; +} + +export interface FileSemanticChanges { + loading: boolean; + changes: SemanticDiffChange[]; + binaryChanges: SemanticDiffBinaryChange[]; +} + +const EMPTY: FileSemanticChanges = { loading: false, changes: [], binaryChanges: [] }; + +/** Named (non-orphan) semantic changes for a single file, or empty when disabled/unavailable. */ +export function useFileSemanticChanges( + filePath: string, + rawPatch: string, + enabled: boolean, +): FileSemanticChanges { + const [state, setState] = useState(enabled ? { ...EMPTY, loading: true } : EMPTY); + + useEffect(() => { + if (!enabled) { + setState(EMPTY); + return; + } + + let cancelled = false; + setState((prev) => (prev.loading ? prev : { ...prev, loading: true })); + + loadSemanticDiff(rawPatch).then((data) => { + if (cancelled) return; + if (data.status !== 'ok') { + setState(EMPTY); + return; + } + setState({ + loading: false, + changes: data.changes.filter((c) => c.filePath === filePath && !isOrphanChange(c)), + binaryChanges: data.binaryChanges.filter((c) => c.filePath === filePath), + }); + }); + + return () => { + cancelled = true; + }; + }, [filePath, rawPatch, enabled]); + + return state; +} diff --git a/packages/review-editor/hooks/usePRStack.ts b/packages/review-editor/hooks/usePRStack.ts index c42d2975..9cc22c76 100644 --- a/packages/review-editor/hooks/usePRStack.ts +++ b/packages/review-editor/hooks/usePRStack.ts @@ -1,5 +1,6 @@ import { useState, useCallback, type RefObject } from 'react'; import type { PRDiffScope } from '@plannotator/shared/pr-stack'; +import type { SemanticDiffAdvert } from '@plannotator/shared/semantic-diff-types'; export interface PRSwitchResponse { rawPatch: string; @@ -12,6 +13,7 @@ export interface PRSwitchResponse { repoInfo?: unknown; viewedFiles?: string[]; error?: string; + semanticDiff?: SemanticDiffAdvert; } export interface PRStackCallbacks { diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index 8c8e709a..fdf0ce10 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -294,6 +294,238 @@ diffs-container { background: oklch(from var(--primary) l c h / 0.3); } +/* Semantic diff terminal view */ +.semantic-diff-panel { + height: 100%; + min-width: 0; + overflow: auto; + background: var(--background); + color: var(--foreground); +} + +/* Centered terminal column — capped width, auto margins */ +.semantic-diff-terminal { + width: 100%; + max-width: 56rem; + margin: 0 auto; + padding: 1.5rem; + font-family: var(--diff-font-override, var(--font-mono)); + font-size: 0.8125rem; + line-height: 1.55; + font-variant-numeric: tabular-nums; +} + +/* File section — flat, no box; grouping comes from a quiet underlined header */ +.semantic-diff-file { + margin: 0 0 1.5rem; +} + +.semantic-diff-file-header { + display: flex; + align-items: baseline; + min-width: 0; + padding: 0 0.5rem 0.375rem; + margin-bottom: 0.25rem; + border-bottom: 1px solid oklch(from var(--border) l c h / 0.7); +} + +.semantic-diff-path { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.semantic-diff-path-dir { + color: var(--muted-foreground); +} + +.semantic-diff-path-name { + color: var(--foreground); + font-weight: 600; +} + +.semantic-diff-rows { + padding: 0; +} + +.semantic-diff-loading, +.semantic-diff-empty, +.semantic-diff-error, +.semantic-diff-summary { + color: var(--muted-foreground); +} + +.semantic-diff-error { + color: var(--destructive); +} + +/* Entity row — grid-aligned, full-width, rounded hover target */ +.semantic-diff-row { + display: grid; + grid-template-columns: 2ch 9ch minmax(0, 1fr) max-content; + column-gap: 1ch; + align-items: baseline; + width: 100%; + min-height: 1.75rem; + border: 0; + border-radius: var(--radius-sm); + padding: 0.1875rem 0.5rem; + background: transparent; + color: var(--foreground); + font: inherit; + text-align: left; + cursor: pointer; +} + +.semantic-diff-retry { + display: inline-flex; + align-items: center; + min-height: 1.75rem; + margin-top: 0.5rem; + border: 0; + padding: 0; + background: transparent; + color: var(--foreground); + font: inherit; + cursor: pointer; +} + +.semantic-diff-row:focus-visible, +.semantic-diff-retry:focus-visible { + outline: 1px solid var(--ring); + outline-offset: 2px; +} + +@media (hover: hover) { + .semantic-diff-row:hover, + .semantic-diff-retry:hover { + background: oklch(from var(--foreground) l c h / 0.045); + } +} + +/* Nudge the add/remove glyphs up slightly so they read at the weight of the + modified triangle; line-height pinned so the larger size doesn't grow rows. */ +.semantic-diff-symbol-added, +.semantic-diff-symbol-deleted { + font-size: 1.15em; + line-height: 1; +} + +.semantic-diff-symbol-added { + color: var(--success); +} + +.semantic-diff-symbol-deleted { + color: var(--destructive); +} + +.semantic-diff-symbol-modified, +.semantic-diff-symbol-renamed, +.semantic-diff-symbol-moved, +.semantic-diff-symbol-reordered { + color: var(--primary); +} + +.semantic-diff-kind, +.semantic-diff-status { + color: var(--muted-foreground); +} + +.semantic-diff-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.semantic-diff-status { + justify-self: end; + white-space: nowrap; +} + +.semantic-diff-summary { + margin-top: 0.5rem; + padding: 0 0.5rem; +} + +/* "sem · N" pill in a diff file header */ +.semantic-file-badge { + display: inline-flex; + align-items: center; + gap: 0.3125rem; + height: 1.5rem; + padding: 0 0.4375rem; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.6875rem; + line-height: 1; + cursor: pointer; + transition: color 120ms ease, background 120ms ease; +} + +.semantic-file-badge:hover, +.semantic-file-badge[data-state="open"] { + color: var(--foreground); + background: var(--muted); +} + +.semantic-file-badge:focus-visible { + outline: 1px solid var(--ring); + outline-offset: 2px; +} + +.semantic-file-badge-label { + font-weight: 600; + letter-spacing: 0.02em; +} + +.semantic-file-badge-count { + padding: 0.0625rem 0.3125rem; + border-radius: calc(var(--radius-sm) - 2px); + background: oklch(from var(--primary) l c h / 0.14); + color: var(--primary); + font-variant-numeric: tabular-nums; +} + +/* Hover popover — same terminal rows as the panel, shadcn surface */ +.semantic-diff-popover { + width: min(30rem, 90vw); + max-height: min(24rem, 70vh); + overflow: auto; + padding: 0.375rem; + background: var(--popover); + color: var(--popover-foreground); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + font-family: var(--diff-font-override, var(--font-mono)); + font-size: 0.8125rem; + line-height: 1.55; + font-variant-numeric: tabular-nums; + outline: none; +} + +.semantic-diff-popover-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem 0.375rem; + margin-bottom: 0.25rem; + border-bottom: 1px solid var(--border); + color: var(--muted-foreground); +} + +.semantic-diff-popover-path { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--foreground); +} + /* ===================================================== Conventional Comments — Diagnostic-style labels Visual language: VS Code severity indicators. diff --git a/packages/server/review-workspace.test.ts b/packages/server/review-workspace.test.ts index bbc65e6c..346e6612 100644 --- a/packages/server/review-workspace.test.ts +++ b/packages/server/review-workspace.test.ts @@ -6,7 +6,7 @@ */ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; @@ -21,9 +21,11 @@ import { type WorkspaceRepoRuntimeState, } from "./review-workspace"; import { startReviewServer } from "./review"; -import type { DiffType, GitContext } from "./vcs"; +import { getVcsContext, type DiffType, type GitContext } from "./vcs"; const tempDirs: string[] = []; +const originalSemPath = process.env.PLANNOTATOR_SEM_PATH; +const originalDataDir = process.env.PLANNOTATOR_DATA_DIR; function makeTempDir(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); @@ -49,13 +51,229 @@ function initRepo(dir: string, initialBranch = "main"): void { git(dir, ["commit", "-m", "initial"]); } +function makeMockSem(dir: string, options: { + versionCounterPath?: string; + runCwdLogPath?: string; + inputLogPath?: string; +} = {}): string { + const semPath = join(dir, "sem"); + writeFileSync( + semPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'if [ "${1:-}" = "--version" ]; then', + ...(options.versionCounterPath ? [` printf x >> ${JSON.stringify(options.versionCounterPath)}`] : []), + ' echo "sem 0.8.0"', + " exit 0", + "fi", + ...(options.runCwdLogPath ? [`pwd >> ${JSON.stringify(options.runCwdLogPath)}`] : []), + ...(options.inputLogPath ? [`cat > ${JSON.stringify(options.inputLogPath)}`] : ["cat >/dev/null"]), + "cat <<'JSON'", + JSON.stringify({ + summary: { fileCount: 1, added: 1, modified: 0, deleted: 0, moved: 0, renamed: 0, reordered: 0, binary: 0, orphan: 0, total: 1 }, + changes: [ + { + entityId: "src/app.ts::function::created", + changeType: "added", + entityType: "function", + entityName: "created", + filePath: "src/app.ts", + startLine: 1, + endLine: 3, + }, + ], + binaryChanges: [], + }), + "JSON", + "", + ].join("\n"), + "utf-8", + ); + chmodSync(semPath, 0o755); + return semPath; +} + afterEach(() => { + if (originalSemPath === undefined) { + delete process.env.PLANNOTATOR_SEM_PATH; + } else { + process.env.PLANNOTATOR_SEM_PATH = originalSemPath; + } + if (originalDataDir === undefined) { + delete process.env.PLANNOTATOR_DATA_DIR; + } else { + process.env.PLANNOTATOR_DATA_DIR = originalDataDir; + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); describe("review-workspace", () => { + describe("semantic diff API", () => { + const rawPatch = [ + "diff --git a/src/app.ts b/src/app.ts", + "new file mode 100644", + "index 0000000..1111111", + "--- /dev/null", + "+++ b/src/app.ts", + "@@ -0,0 +1,3 @@", + "+export function created() {", + "+ return true;", + "+}", + "", + ].join("\n"); + + it("advertises semantic diff availability and serves parsed sem output", async () => { + const dir = makeTempDir("plannotator-sem-server-"); + const dataDir = makeTempDir("plannotator-sem-data-"); + const cwdLogPath = join(dir, "cwd-log"); + process.env.PLANNOTATOR_DATA_DIR = dataDir; + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath }); + + const server = await startReviewServer({ + rawPatch, + gitRef: "test", + origin: "claude-code", + htmlContent: "review", + }); + + try { + const diffPayload = await fetch(`${server.url}/api/diff`).then((response) => response.json()) as { + semanticDiff?: { available: boolean; semVersion?: string; semSource?: string }; + }; + expect(diffPayload.semanticDiff).toMatchObject({ + available: true, + semVersion: "0.8.0", + semSource: "env", + }); + + const semanticPayload = await fetch(`${server.url}/api/semantic-diff?fileExt=.ts`).then((response) => response.json()) as { + status: string; + summary?: { added: number; fileCount: number }; + changes?: Array<{ entityType: string; entityName: string; filePath: string }>; + }; + expect(semanticPayload).toMatchObject({ + status: "ok", + summary: { added: 1, fileCount: 1 }, + changes: [ + { entityType: "function", entityName: "created", filePath: "src/app.ts" }, + ], + }); + expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe( + realpathSync(join(dataDir, "semantic-diff", "patch-only")), + ); + } finally { + server.stop(); + } + }); + + it("runs semantic diff from the local agent cwd when one is available", async () => { + const dir = makeTempDir("plannotator-sem-agent-"); + const agentCwd = makeTempDir("plannotator-sem-agent-cwd-"); + const cwdLogPath = join(dir, "cwd-log"); + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath }); + + const server = await startReviewServer({ + rawPatch, + gitRef: "test", + origin: "claude-code", + agentCwd, + htmlContent: "review", + }); + + try { + const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as { + status: string; + }; + expect(semanticPayload.status).toBe("ok"); + expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(agentCwd)); + } finally { + server.stop(); + } + }); + + it("runs semantic diff from the local git context cwd in local review mode", async () => { + const dir = makeTempDir("plannotator-sem-local-"); + const repoDir = makeTempDir("plannotator-sem-local-repo-"); + const cwdLogPath = join(dir, "cwd-log"); + initRepo(repoDir); + const gitContext = await getVcsContext(repoDir); + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { runCwdLogPath: cwdLogPath }); + + const server = await startReviewServer({ + rawPatch, + gitRef: "test", + origin: "claude-code", + diffType: "unstaged", + gitContext, + htmlContent: "review", + }); + + try { + const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as { + status: string; + }; + expect(semanticPayload.status).toBe("ok"); + expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(repoDir)); + } finally { + server.stop(); + } + }); + + it("caches semantic diff availability probes for the session cwd", async () => { + const dir = makeTempDir("plannotator-sem-cache-"); + const versionCounterPath = join(dir, "version-count"); + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(dir, { versionCounterPath }); + + const server = await startReviewServer({ + rawPatch, + gitRef: "test", + origin: "claude-code", + htmlContent: "review", + }); + + try { + await fetch(`${server.url}/api/diff`).then((response) => response.json()); + await fetch(`${server.url}/api/diff`).then((response) => response.json()); + expect(readFileSync(versionCounterPath, "utf-8")).toBe("x"); + } finally { + server.stop(); + } + }); + + it("hides semantic diff from /api/diff when sem cannot be resolved", async () => { + const dir = makeTempDir("plannotator-sem-missing-server-"); + process.env.PLANNOTATOR_SEM_PATH = join(dir, "missing-sem"); + + const server = await startReviewServer({ + rawPatch, + gitRef: "test", + origin: "claude-code", + htmlContent: "review", + }); + + try { + const diffPayload = await fetch(`${server.url}/api/diff`).then((response) => response.json()) as { + semanticDiff?: { available: boolean }; + }; + expect(diffPayload.semanticDiff).toEqual({ available: false }); + + const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as { + status: string; + reason?: string; + }; + expect(semanticPayload).toMatchObject({ + status: "unavailable", + reason: "sem-path-missing", + }); + } finally { + server.stop(); + } + }); + }); + describe("prefixPatchPaths", () => { it("prefixes diff headers with the repo label", () => { const patch = [ @@ -696,6 +914,10 @@ describe("review-workspace", () => { it("serves combined diffs and maps prefixed paths back to child repos", async () => { const root = makeTempDir("plannotator-workspace-server-"); + const semDir = makeTempDir("plannotator-workspace-switch-sem-"); + const cwdLogPath = join(semDir, "cwd-log"); + const inputLogPath = join(semDir, "input.patch"); + process.env.PLANNOTATOR_SEM_PATH = makeMockSem(semDir, { runCwdLogPath: cwdLogPath, inputLogPath }); const api = join(root, "api"); const web = join(root, "web"); mkdirSync(api, { recursive: true }); @@ -730,6 +952,7 @@ describe("review-workspace", () => { diffType?: string; diffOptions?: Array<{ id: string }>; agentCwd?: string; + semanticDiff?: { available: boolean }; }; expect(diffPayload.mode).toBe("workspace"); expect(diffPayload.diffType).toBe("workspace-current"); @@ -740,19 +963,35 @@ describe("review-workspace", () => { "workspace-last", ]); expect(diffPayload.agentCwd).toBe(root); + expect(diffPayload.semanticDiff).toEqual(expect.objectContaining({ available: true })); expect("workspace" in diffPayload).toBe(false); expect(diffPayload.rawPatch).toContain("diff --git a/api/tracked.txt b/api/tracked.txt"); expect(diffPayload.rawPatch).toContain("diff --git a/web/new.txt b/web/new.txt"); + const semanticPayload = await fetch(`${server.url}/api/semantic-diff`).then((response) => response.json()) as { + status: string; + }; + expect(semanticPayload.status).toBe("ok"); + expect(realpathSync(readFileSync(cwdLogPath, "utf-8").trim())).toBe(realpathSync(root)); + const semInput = readFileSync(inputLogPath, "utf-8"); + expect(semInput).toContain("diff --git a/api/tracked.txt b/api/tracked.txt"); + expect(semInput).toContain("diff --git a/web/new.txt b/web/new.txt"); + const lastResponse = await fetch(`${server.url}/api/diff/switch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diffType: "workspace-last", hideWhitespace: true }), }); expect(lastResponse.status).toBe(200); - const lastPayload = await lastResponse.json() as { diffType?: string; rawPatch: string; diffOptions?: Array<{ id: string }> }; + const lastPayload = await lastResponse.json() as { + diffType?: string; + rawPatch: string; + diffOptions?: Array<{ id: string }>; + semanticDiff?: { available: boolean }; + }; expect(lastPayload.diffType).toBe("workspace-last"); expect(lastPayload.diffOptions?.map((option) => option.id)).toContain("workspace-current"); + expect(lastPayload.semanticDiff).toEqual(expect.objectContaining({ available: true })); expect(lastPayload.rawPatch).toContain("diff --git a/api/tracked.txt b/api/tracked.txt"); const currentResponse = await fetch(`${server.url}/api/diff/switch`, { diff --git a/packages/server/review.ts b/packages/server/review.ts index 55b30b3a..71697347 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -14,6 +14,16 @@ import type { Origin } from "@plannotator/shared/agents"; import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, detectRemoteDefaultCompareTarget, gitRuntime } from "./vcs"; import { basename } from "node:path"; import { parseWorktreeDiffType, resolveBaseBranch } from "@plannotator/shared/review-core"; +import { + createDefaultSemanticDiffRuntime, + getSemanticDiffAvailability, + getSemanticDiffScratchCwd, + runSemanticDiff, + semanticDiffCacheKey, + semanticDiffFileExtsFromSearchParams, + SemanticDiffResponseCache, +} from "@plannotator/shared/semantic-diff"; +import type { SemanticDiffAvailability, SemanticDiffResponse } from "@plannotator/shared/semantic-diff-types"; import { getPRDiffScopeOptions, getPRStackInfo, @@ -205,6 +215,68 @@ export async function startReviewServer( if (!workspace) return undefined; return workspace.getPromptContext(); }; + const semanticDiffScratchCwd = getSemanticDiffScratchCwd(); + const resolveSemanticDiffCwd = (): string => { + if (workspace) return workspace.root; + if (options.worktreePool && prMetadata) { + const poolPath = options.worktreePool.resolve(prMetadata.url); + if (poolPath) return poolPath; + } + if (options.agentCwd) return options.agentCwd; + if (gitContext) { + const vcsCwd = resolveVcsCwd(currentDiffType as DiffType, gitContext.cwd); + if (vcsCwd) return vcsCwd; + if (gitContext.cwd) return gitContext.cwd; + } + return semanticDiffScratchCwd; + }; + const semanticDiffCache = new SemanticDiffResponseCache(); + const semanticDiffAvailabilityCache = new Map>(); + + const createSemanticDiffRuntime = (cwd: string) => ({ + ...createDefaultSemanticDiffRuntime(), + cwd, + }); + + const getSemanticDiffAvailabilityForCwd = (cwd: string): Promise => { + const cached = semanticDiffAvailabilityCache.get(cwd); + if (cached) return cached; + + const next: Promise = getSemanticDiffAvailability(createSemanticDiffRuntime(cwd)).catch((error) => ({ + available: false, + reason: "sem-probe-failed", + message: error instanceof Error ? error.message : String(error), + })); + semanticDiffAvailabilityCache.set(cwd, next); + return next; + }; + + const getSemanticDiffAdvert = async () => { + const availability = await getSemanticDiffAvailabilityForCwd(resolveSemanticDiffCwd()); + return { + available: availability.available, + ...(availability.semVersion && { semVersion: availability.semVersion }), + ...(availability.semSource && { semSource: availability.semSource }), + }; + }; + + const getSemanticDiff = async (url: URL): Promise => { + const cwd = resolveSemanticDiffCwd(); + const fileExts = semanticDiffFileExtsFromSearchParams(url.searchParams); + const cacheKey = semanticDiffCacheKey({ rawPatch: currentPatch, cwd, fileExts }); + const cached = semanticDiffCache.get(cacheKey, currentPatch); + if (cached) return cached; + + const result = await runSemanticDiff( + { rawPatch: currentPatch, cwd, fileExts }, + createSemanticDiffRuntime(cwd), + ); + if (result.status === "ok") { + semanticDiffCache.set(cacheKey, currentPatch, result); + } + return result; + }; + const agentJobs = createAgentJobHandler({ mode: "review", getServerUrl: () => serverUrl, @@ -511,10 +583,16 @@ export async function startReviewServer( }), ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), ...(currentError && { error: currentError }), + semanticDiff: await getSemanticDiffAdvert(), serverConfig: getServerConfig(gitUser), }); } + // API: Get semantic diff content + if (url.pathname === "/api/semantic-diff" && req.method === "GET") { + return Response.json(await getSemanticDiff(url)); + } + // API: Switch diff type (requires local file access) if (url.pathname === "/api/diff/switch" && req.method === "POST") { if (!hasLocalAccess && !workspace) { @@ -556,6 +634,7 @@ export async function startReviewServer( diffOptions: workspace.diffOptions, hideWhitespace: currentHideWhitespace, ...(currentError && { error: currentError }), + semanticDiff: await getSemanticDiffAdvert(), }); } @@ -606,6 +685,7 @@ export async function startReviewServer( hideWhitespace: currentHideWhitespace, ...(updatedContext && { gitContext: updatedContext }), ...(currentError && { error: currentError }), + semanticDiff: await getSemanticDiffAdvert(), }); } catch (err) { const message = @@ -636,6 +716,7 @@ export async function startReviewServer( gitRef: currentGitRef, prDiffScope: currentPRDiffScope, ...(currentError && { error: currentError }), + semanticDiff: await getSemanticDiffAdvert(), }); } @@ -663,6 +744,7 @@ export async function startReviewServer( rawPatch: currentPatch, gitRef: currentGitRef, prDiffScope: currentPRDiffScope, + semanticDiff: await getSemanticDiffAdvert(), }); } catch (err) { const message = @@ -789,6 +871,7 @@ export async function startReviewServer( repoInfo, ...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }), ...(currentError ? { error: currentError } : {}), + semanticDiff: await getSemanticDiffAdvert(), }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to switch PR"; diff --git a/packages/shared/package.json b/packages/shared/package.json index 1436abca..efd8aeb4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -44,7 +44,9 @@ "./annotate-args": "./annotate-args.ts", "./at-reference": "./at-reference.ts", "./code-nav": "./code-nav.ts", - "./goal-setup": "./goal-setup.ts" + "./goal-setup": "./goal-setup.ts", + "./semantic-diff": "./semantic-diff.ts", + "./semantic-diff-types": "./semantic-diff-types.ts" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", diff --git a/packages/shared/pr-gitlab.test.ts b/packages/shared/pr-gitlab.test.ts index f0ca6cf2..aecf4342 100644 --- a/packages/shared/pr-gitlab.test.ts +++ b/packages/shared/pr-gitlab.test.ts @@ -1,53 +1,91 @@ import { describe, expect, test } from "bun:test"; -import { parsePaginatedArray } from "./pr-gitlab"; +import { fetchGlMR } from "./pr-gitlab"; +import type { PRRuntime } from "./pr-types"; -describe("parsePaginatedArray", () => { - test("parses a single-page array", () => { - const stdout = JSON.stringify([{ a: 1 }, { a: 2 }]); - expect(parsePaginatedArray<{ a: number }>(stdout)).toEqual([{ a: 1 }, { a: 2 }]); - }); - - test("merges adjacent JSON arrays from --paginate output", () => { - const stdout = JSON.stringify([{ a: 1 }]) + JSON.stringify([{ a: 2 }, { a: 3 }]); - expect(parsePaginatedArray<{ a: number }>(stdout)).toEqual([ - { a: 1 }, - { a: 2 }, - { a: 3 }, - ]); - }); - - test("merges three or more pages with whitespace between them", () => { - const stdout = [ - JSON.stringify([1, 2]), - JSON.stringify([3, 4]), - JSON.stringify([5]), +describe("fetchGlMR", () => { + test("uses GitLab raw diffs so binary markers and collapsed files are preserved", async () => { + const calls: string[] = []; + const rawPatch = [ + "diff --git a/src/app.ts b/src/app.ts", + "index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -0,0 +1,3 @@", + "+export function created() {", + "+ return true;", + "+}", + "diff --git a/package-lock.json b/package-lock.json", + "index 2222222222222222222222222222222222222222..3333333333333333333333333333333333333333 100644", + "--- a/package-lock.json", + "+++ b/package-lock.json", + "@@ -1,3 +1,3 @@", + "- \"old\": true", + "+ \"new\": true", + "diff --git a/tests/snap.png b/tests/snap.png", + "new file mode 100644", + "index 0000000000000000000000000000000000000000..4444444444444444444444444444444444444444", + "Binary files /dev/null and b/tests/snap.png differ", + "", ].join("\n"); - expect(parsePaginatedArray(stdout)).toEqual([1, 2, 3, 4, 5]); - }); - test("handles strings containing brackets without splitting prematurely", () => { - // Diff content frequently contains `][` inside JSON strings — must not be - // confused with a page boundary. - const page1 = [{ diff: "before][after", new_path: "a" }]; - const page2 = [{ diff: "second", new_path: "b" }]; - const stdout = JSON.stringify(page1) + JSON.stringify(page2); - expect(parsePaginatedArray(stdout)).toEqual([...page1, ...page2]); - }); + const runtime: PRRuntime = { + async runCommand(command, args) { + calls.push([command, ...args].join(" ")); + const endpoint = args[1]; + if (endpoint === "projects/group%2Fproject/merge_requests/42/raw_diffs") { + return { + stdout: rawPatch, + stderr: "", + exitCode: 0, + }; + } + if (endpoint === "projects/group%2Fproject/merge_requests/42") { + return { + stdout: JSON.stringify({ + title: "Add app", + author: { username: "reviewer" }, + source_branch: "feature/app", + target_branch: "main", + diff_refs: { + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + start_sha: "a".repeat(40), + }, + web_url: "https://gitlab.com/group/project/-/merge_requests/42", + }), + stderr: "", + exitCode: 0, + }; + } + if (endpoint === "projects/group%2Fproject") { + return { + stdout: JSON.stringify({ default_branch: "main" }), + stderr: "", + exitCode: 0, + }; + } + return { stdout: "", stderr: `unexpected endpoint: ${endpoint}`, exitCode: 1 }; + }, + }; - test("handles escaped quotes inside strings", () => { - const page1 = [{ diff: 'has \\"quote\\" and ] bracket', new_path: "a" }]; - const page2 = [{ diff: "second", new_path: "b" }]; - const stdout = JSON.stringify(page1) + JSON.stringify(page2); - expect(parsePaginatedArray(stdout)).toEqual([...page1, ...page2]); - }); + const result = await fetchGlMR(runtime, { + platform: "gitlab", + host: "gitlab.com", + projectPath: "group/project", + iid: 42, + }); - test("returns empty array for empty input", () => { - expect(parsePaginatedArray("")).toEqual([]); - expect(parsePaginatedArray(" \n")).toEqual([]); - }); - - test("handles empty pages mixed with non-empty ones", () => { - const stdout = "[]" + JSON.stringify([{ a: 1 }]) + "[]"; - expect(parsePaginatedArray<{ a: number }>(stdout)).toEqual([{ a: 1 }]); + expect(result.metadata).toMatchObject({ + platform: "gitlab", + projectPath: "group/project", + iid: 42, + baseBranch: "main", + headBranch: "feature/app", + }); + expect(result.rawPatch).toBe(rawPatch); + expect(result.rawPatch).toContain("diff --git a/package-lock.json b/package-lock.json"); + expect(result.rawPatch).toContain("Binary files /dev/null and b/tests/snap.png differ"); + expect(calls).toContain("glab api projects/group%2Fproject/merge_requests/42/raw_diffs"); + expect(calls.some((call) => call.includes("/diffs?per_page=100"))).toBe(false); }); }); diff --git a/packages/shared/pr-gitlab.ts b/packages/shared/pr-gitlab.ts index f7f56fa6..5a3448a9 100644 --- a/packages/shared/pr-gitlab.ts +++ b/packages/shared/pr-gitlab.ts @@ -33,105 +33,6 @@ function apiArgs(host: string, endpoint: string, extra: string[] = []): string[] return args; } -/** Shape of each entry from the GitLab merge_request diffs API */ -interface GitLabDiffEntry { - diff: string; - old_path: string; - new_path: string; - new_file: boolean; - deleted_file: boolean; - renamed_file: boolean; -} - -/** - * Parse output of `glab api --paginate`. - * - * glab concatenates pages as adjacent JSON arrays (`[...][...]`) which is not - * valid JSON. Walk the output, split it into top-level arrays, and merge them. - * Single-page output (the common case) round-trips through the same path. - */ -export function parsePaginatedArray(stdout: string): T[] { - const trimmed = stdout.trim(); - if (!trimmed) return []; - - const slices: string[] = []; - let depth = 0; - let inString = false; - let escape = false; - let start = -1; - - for (let i = 0; i < trimmed.length; i++) { - const c = trimmed[i]; - if (inString) { - if (escape) { - escape = false; - } else if (c === "\\") { - escape = true; - } else if (c === '"') { - inString = false; - } - continue; - } - if (c === '"') { - inString = true; - continue; - } - if (c === "[" || c === "{") { - if (depth === 0 && c === "[") start = i; - depth++; - } else if (c === "]" || c === "}") { - depth--; - if (depth === 0 && c === "]" && start !== -1) { - slices.push(trimmed.slice(start, i + 1)); - start = -1; - } - } - } - - if (slices.length === 0) { - return JSON.parse(trimmed) as T[]; - } - - const merged: T[] = []; - for (const slice of slices) { - const page = JSON.parse(slice) as T[]; - if (Array.isArray(page)) merged.push(...page); - } - return merged; -} - -/** - * Reconstruct a unified patch from GitLab's merge_request diffs API response. - * - * Each entry has: { diff, old_path, new_path, new_file, deleted_file, renamed_file } - * We construct proper `diff --git` headers that the UI parser expects. - */ -function reconstructPatch(diffs: GitLabDiffEntry[]): string { - const parts: string[] = []; - - for (const d of diffs) { - const aPath = d.new_file ? "/dev/null" : `a/${d.old_path}`; - const bPath = d.deleted_file ? "/dev/null" : `b/${d.new_path}`; - const displayOld = d.new_file ? d.new_path : d.old_path; - const displayNew = d.deleted_file ? d.old_path : d.new_path; - - let header = `diff --git a/${displayOld} b/${displayNew}`; - if (d.renamed_file) { - header += `\nrename from ${d.old_path}\nrename to ${d.new_path}`; - } - if (d.new_file) { - header += "\nnew file mode 100644"; - } - if (d.deleted_file) { - header += "\ndeleted file mode 100644"; - } - - parts.push(`${header}\n--- ${aPath}\n+++ ${bPath}\n${d.diff}`); - } - - return parts.join(""); -} - // --- Auth --- export async function checkGlAuth(runtime: PRRuntime, host: string): Promise { @@ -170,9 +71,11 @@ export async function fetchGlMR( ): Promise<{ metadata: PRMetadata; rawPatch: string }> { const encoded = encodeProject(ref.projectPath); - // Fetch diff and metadata in parallel via glab api (supports --hostname for self-hosted) + // Fetch raw unified diff text instead of reconstructing from the JSON diffs API. + // The JSON API can omit collapsed/generated file contents and loses Git's + // binary marker shape; raw_diffs preserves what downstream diff parsers expect. const [diffResult, viewResult] = await Promise.all([ - runtime.runCommand("glab", apiArgs(ref.host, `projects/${encoded}/merge_requests/${ref.iid}/diffs?per_page=100`, ["--paginate"])), + runtime.runCommand("glab", apiArgs(ref.host, `projects/${encoded}/merge_requests/${ref.iid}/raw_diffs`)), runtime.runCommand("glab", apiArgs(ref.host, `projects/${encoded}/merge_requests/${ref.iid}`)), ]); @@ -188,9 +91,7 @@ export async function fetchGlMR( ); } - // Reconstruct unified patch from structured API response - const diffs = parsePaginatedArray(diffResult.stdout); - const rawPatch = reconstructPatch(diffs); + const rawPatch = diffResult.stdout; const raw = JSON.parse(viewResult.stdout) as { title: string; diff --git a/packages/shared/semantic-diff-types.ts b/packages/shared/semantic-diff-types.ts new file mode 100644 index 00000000..7b497ba4 --- /dev/null +++ b/packages/shared/semantic-diff-types.ts @@ -0,0 +1,76 @@ +export type SemanticDiffStatus = "ok" | "unavailable" | "error"; + +export interface SemanticDiffSummary { + fileCount: number; + added: number; + modified: number; + deleted: number; + moved: number; + renamed: number; + reordered: number; + binary: number; + orphan: number; + total: number; +} + +export interface SemanticDiffChange { + entityId: string | null; + changeType: string; + entityType: string; + entityName: string; + oldEntityName: string | null; + filePath: string; + oldFilePath: string | null; + startLine: number | null; + endLine: number | null; + oldStartLine: number | null; + oldEndLine: number | null; + structuralChange: boolean | null; +} + +export interface SemanticDiffBinaryChange { + changeType: "binary"; + filePath: string; + oldFilePath: string | null; + fileStatus: string | null; +} + +export interface SemanticDiffOkResponse { + status: "ok"; + summary: SemanticDiffSummary; + changes: SemanticDiffChange[]; + binaryChanges: SemanticDiffBinaryChange[]; + semVersion: string; + semSource: string; +} + +export interface SemanticDiffUnavailableResponse { + status: "unavailable"; + reason: string; + message: string; +} + +export interface SemanticDiffErrorResponse { + status: "error"; + reason: string; + message: string; + exitCode?: number; + stderr?: string; + semVersion?: string; + semSource?: string; +} + +export type SemanticDiffResponse = + | SemanticDiffOkResponse + | SemanticDiffUnavailableResponse + | SemanticDiffErrorResponse; + +export interface SemanticDiffAvailability { + available: boolean; + reason?: string; + message?: string; + semVersion?: string; + semSource?: string; +} + +export type SemanticDiffAdvert = Pick; diff --git a/packages/shared/semantic-diff.test.ts b/packages/shared/semantic-diff.test.ts new file mode 100644 index 00000000..b178a66b --- /dev/null +++ b/packages/shared/semantic-diff.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, test } from "bun:test"; +import { + getManagedSemBinaryPath, + getSemanticDiffAvailability, + parseSemVersion, + runSemanticDiff, + semanticDiffCacheKey, + semanticDiffFileExtsFromSearchParams, + SemanticDiffResponseCache, + type SemanticDiffRuntime, +} from "./semantic-diff"; +import type { SemanticDiffResponse } from "./semantic-diff-types"; + +interface MockCommand { + version?: string; + diff?: string; + stderr?: string; + exitCode?: number; +} + +function makeRuntime(options: { + cwd?: string; + env?: Record; + files?: string[]; + commands?: Record; + pathDelimiter?: string; + platform?: NodeJS.Platform; +} = {}): SemanticDiffRuntime & { calls: Array<{ command: string; args: string[]; input?: string }> } { + const calls: Array<{ command: string; args: string[]; input?: string }> = []; + const files = new Set(options.files ?? []); + const commands = options.commands ?? {}; + + return { + calls, + env: options.env ?? {}, + cwd: options.cwd ?? "/repo", + dataDir: "/home/user/.plannotator", + pathDelimiter: options.pathDelimiter ?? ":", + platform: options.platform ?? "linux", + fileExists(path) { + return files.has(path); + }, + async runCommand(command, args, runOptions) { + calls.push({ command, args, input: runOptions?.input }); + const mock = commands[command]; + if (!mock) return { stdout: "", stderr: "not found", exitCode: 1, error: "not found" }; + if (args.includes("--version")) { + return { stdout: mock.version ?? "", stderr: "", exitCode: mock.version ? 0 : 1 }; + } + return { + stdout: mock.diff ?? "", + stderr: mock.stderr ?? "", + exitCode: mock.exitCode ?? 0, + }; + }, + }; +} + +describe("semantic diff runner", () => { + test("parses real sem version output and rejects other sem commands", () => { + expect(parseSemVersion("sem 0.8.0\n")).toBe("0.8.0"); + expect(parseSemVersion("sem 0.8.0-dev+abc\n")).toBe("0.8.0-dev+abc"); + expect(parseSemVersion("GNU parallel 20250122\n")).toBeNull(); + }); + + test("reports unavailable when sem cannot be resolved", async () => { + const runtime = makeRuntime(); + await expect(getSemanticDiffAvailability(runtime)).resolves.toMatchObject({ + available: false, + reason: "sem-not-found", + }); + }); + + test("validates PLANNOTATOR_SEM_PATH before using it", async () => { + const runtime = makeRuntime({ + env: { PLANNOTATOR_SEM_PATH: "/missing/sem" }, + }); + + await expect(runSemanticDiff({ rawPatch: "diff --git a/a.ts b/a.ts\n" }, runtime)).resolves.toMatchObject({ + status: "unavailable", + reason: "sem-path-missing", + }); + }); + + test("runs sem with patch input and normalized file extensions", async () => { + const runtime = makeRuntime({ + env: { PLANNOTATOR_SEM_PATH: "mock-sem" }, + commands: { + "mock-sem": { + version: "sem 0.8.0", + diff: JSON.stringify({ + summary: { fileCount: 1, added: 1, modified: 0, deleted: 0, total: 1 }, + changes: [ + { + entityId: "src/a.ts::function::hello", + changeType: "added", + entityType: "function", + entityName: "hello", + filePath: "src/a.ts", + startLine: 3, + endLine: 5, + }, + ], + }), + }, + }, + }); + + const result = await runSemanticDiff({ + rawPatch: "diff --git a/src/a.ts b/src/a.ts\n@@ -0,0 +1 @@\n+export function hello() {}\n", + fileExts: ["ts", ".tsx", "ts"], + }, runtime); + + expect(result).toMatchObject({ + status: "ok", + summary: { fileCount: 1, added: 1, total: 1 }, + changes: [{ entityType: "function", entityName: "hello", filePath: "src/a.ts" }], + semVersion: "0.8.0", + semSource: "env", + }); + expect(runtime.calls[1]).toMatchObject({ + command: "mock-sem", + args: ["diff", "--patch", "--format", "json", "--file-exts", ".ts", ".tsx"], + input: expect.stringContaining("diff --git"), + }); + }); + + test("does not pass a file extension filter unless one is requested", async () => { + const runtime = makeRuntime({ + env: { PLANNOTATOR_SEM_PATH: "mock-sem" }, + commands: { + "mock-sem": { + version: "sem 0.8.0", + diff: JSON.stringify({ + summary: { fileCount: 0, added: 0, modified: 0, deleted: 0, total: 0 }, + changes: [], + binaryChanges: [], + }), + }, + }, + }); + + await runSemanticDiff({ + rawPatch: "diff --git a/src/a.py b/src/a.py\n@@ -1 +1 @@\n-a\n+b\n", + }, runtime); + + expect(runtime.calls[1]).toMatchObject({ + command: "mock-sem", + args: ["diff", "--patch", "--format", "json"], + }); + }); + + test("does not run a sem package from the reviewed cwd", async () => { + const repoSem = "/repo/node_modules/@ataraxy-labs/sem/vendor/sem"; + const runtime = makeRuntime({ + cwd: "/server", + files: [repoSem], + commands: { + [repoSem]: { + version: "sem 0.8.0", + diff: JSON.stringify({ + summary: { fileCount: 1, added: 1, modified: 0, deleted: 0, total: 1 }, + changes: [ + { + changeType: "added", + entityType: "function", + entityName: "fromRepoPackage", + filePath: "src/a.ts", + }, + ], + binaryChanges: [], + }), + }, + }, + }); + + await expect(runSemanticDiff({ + rawPatch: "diff --git a/src/a.ts b/src/a.ts\n@@ -0,0 +1 @@\n+export function fromRepoPackage() {}\n", + cwd: "/repo", + }, runtime)).resolves.toMatchObject({ + status: "unavailable", + reason: "sem-not-found", + }); + expect(runtime.calls.map(call => call.command)).not.toContain(repoSem); + }); + + test("returns error instead of throwing when sem exits nonzero", async () => { + const runtime = makeRuntime({ + env: { PLANNOTATOR_SEM_PATH: "mock-sem" }, + commands: { + "mock-sem": { + version: "sem 0.8.0", + stderr: "parse failed", + exitCode: 2, + }, + }, + }); + + await expect(runSemanticDiff({ rawPatch: "diff --git a/a.ts b/a.ts\n" }, runtime)).resolves.toMatchObject({ + status: "error", + reason: "sem-exit", + exitCode: 2, + message: "parse failed", + }); + }); + + test("returns error instead of throwing when sem returns invalid JSON", async () => { + const runtime = makeRuntime({ + env: { PLANNOTATOR_SEM_PATH: "mock-sem" }, + commands: { + "mock-sem": { + version: "sem 0.8.0", + diff: "not json", + }, + }, + }); + + await expect(runSemanticDiff({ rawPatch: "diff --git a/a.ts b/a.ts\n" }, runtime)).resolves.toMatchObject({ + status: "error", + reason: "invalid-json", + }); + }); + + test("uses managed sidecar before PATH fallback", async () => { + const managed = getManagedSemBinaryPath("/home/user/.plannotator", "linux"); + const runtime = makeRuntime({ + files: [managed], + commands: { + [managed]: { version: "sem 0.8.0" }, + sem: { version: "sem 0.9.0" }, + }, + }); + + await expect(getSemanticDiffAvailability(runtime)).resolves.toMatchObject({ + available: true, + semVersion: "0.8.0", + semSource: "managed", + }); + expect(runtime.calls[0].command).toBe(managed); + }); + + test("does not fall back to bare sem on Windows when PATH resolution misses", async () => { + const runtime = makeRuntime({ + platform: "win32", + pathDelimiter: ";", + env: { + PATH: "C:/repo;C:/tools", + PATHEXT: ".EXE", + }, + commands: { + sem: { version: "sem 0.8.0" }, + }, + }); + + await expect(getSemanticDiffAvailability(runtime)).resolves.toMatchObject({ + available: false, + reason: "sem-not-found", + }); + expect(runtime.calls).toEqual([]); + }); + + test("resolves an absolute sem.exe from PATH on Windows", async () => { + const semPath = "C:/tools/sem.exe"; + const runtime = makeRuntime({ + platform: "win32", + pathDelimiter: ";", + env: { + PATH: "C:/tools", + PATHEXT: ".EXE", + }, + files: [semPath], + commands: { + [semPath]: { version: "sem 0.8.0" }, + }, + }); + + await expect(getSemanticDiffAvailability(runtime)).resolves.toMatchObject({ + available: true, + semVersion: "0.8.0", + semSource: "path", + }); + expect(runtime.calls[0].command).toBe(semPath); + }); + + test("cache key accounts for patch, cwd, and file extensions", () => { + const a = semanticDiffCacheKey({ rawPatch: "a", cwd: "/repo", fileExts: ["ts"] }); + const b = semanticDiffCacheKey({ rawPatch: "a", cwd: "/repo", fileExts: [".ts"] }); + const c = semanticDiffCacheKey({ rawPatch: "a", cwd: "/other", fileExts: [".ts"] }); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); + + test("parses requested file extensions without applying a default filter", () => { + expect(semanticDiffFileExtsFromSearchParams(new URLSearchParams())).toEqual([]); + expect(semanticDiffFileExtsFromSearchParams(new URLSearchParams("fileExt=ts&fileExts=.tsx,jsx"))).toEqual([ + ".ts", + ".tsx", + ".jsx", + ]); + }); + + test("response cache clears when the patch changes and evicts oldest entries", () => { + const cache = new SemanticDiffResponseCache(1); + const first: SemanticDiffResponse = { + status: "unavailable", + reason: "sem-not-found", + message: "missing", + }; + const second: SemanticDiffResponse = { + status: "error", + reason: "sem-exit", + message: "failed", + }; + + cache.set("a", "patch-a", first); + expect(cache.get("a", "patch-a")).toBe(first); + cache.set("b", "patch-a", second); + expect(cache.get("a", "patch-a")).toBeUndefined(); + expect(cache.get("b", "patch-a")).toBe(second); + expect(cache.get("b", "patch-b")).toBeUndefined(); + }); +}); diff --git a/packages/shared/semantic-diff.ts b/packages/shared/semantic-diff.ts new file mode 100644 index 00000000..80ece8b9 --- /dev/null +++ b/packages/shared/semantic-diff.ts @@ -0,0 +1,520 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { getPlannotatorDataDir } from "./data-dir"; +import type { + SemanticDiffAvailability, + SemanticDiffBinaryChange, + SemanticDiffChange, + SemanticDiffResponse, + SemanticDiffSummary, +} from "./semantic-diff-types"; + +export const PLANNOTATOR_SEM_VERSION = "v0.8.0"; + +const SEM_TIMEOUT_MS = 20_000; +const SEM_VERSION_TIMEOUT_MS = 3_000; +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; + error?: string; + timedOut?: boolean; +} + +export interface SemanticDiffRuntime { + runCommand: ( + command: string, + args: string[], + options?: { cwd?: string; input?: string; timeoutMs?: number }, + ) => Promise; + fileExists: (path: string) => boolean; + env: Record; + cwd: string; + dataDir: string; + pathDelimiter: string; + platform: NodeJS.Platform; +} + +interface SemCandidate { + command: string; + source: string; + explicit: boolean; +} + +export interface ResolvedSem { + command: string; + source: string; + version: string; +} + +type SemResolveFailure = Exclude; + +function defaultRunCommand( + command: string, + args: string[], + options: { cwd?: string; input?: string; timeoutMs?: number } = {}, +): Promise { + return new Promise((resolveResult) => { + let settled = false; + let timer: ReturnType | undefined; + let proc: ReturnType; + + try { + proc = spawn(command, args, { + cwd: options.cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + resolveResult({ + stdout: "", + stderr: "", + exitCode: 1, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdinError: string | undefined; + + const finish = (result: CommandResult) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolveResult(result); + }; + + if (options.timeoutMs) { + timer = setTimeout(() => { + try { + proc.kill(); + } catch { + // Ignore kill failures; process close/error will settle if needed. + } + finish({ + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + exitCode: 1, + error: `command timed out after ${options.timeoutMs}ms`, + timedOut: true, + }); + }, options.timeoutMs); + } + + proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + proc.on("error", (error) => { + finish({ + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + exitCode: 1, + error: error.message, + }); + }); + proc.on("close", (code) => { + finish({ + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + exitCode: code ?? 1, + ...(stdinError && { error: stdinError }), + }); + }); + + proc.stdin?.on("error", (error) => { + stdinError = error.message; + }); + + try { + if (options.input !== undefined) { + proc.stdin?.write(options.input); + } + proc.stdin?.end(); + } catch (error) { + stdinError = error instanceof Error ? error.message : String(error); + } + }); +} + +export function createDefaultSemanticDiffRuntime(): SemanticDiffRuntime { + return { + runCommand: defaultRunCommand, + fileExists: existsSync, + env: process.env, + cwd: process.cwd(), + dataDir: getPlannotatorDataDir(), + pathDelimiter: delimiter, + platform: process.platform, + }; +} + +function semBinaryName(platform: NodeJS.Platform): string { + return platform === "win32" ? "sem.exe" : "sem"; +} + +export function getManagedSemBinaryPath( + dataDir = getPlannotatorDataDir(), + platform: NodeJS.Platform = process.platform, +): string { + return join(dataDir, "vendor", "sem", PLANNOTATOR_SEM_VERSION, semBinaryName(platform)); +} + +export function getSemanticDiffScratchCwd(dataDir = getPlannotatorDataDir()): string { + const primary = join(dataDir, "semantic-diff", "patch-only"); + try { + mkdirSync(primary, { recursive: true }); + return primary; + } catch { + const fallback = join(tmpdir(), "plannotator-semantic-diff"); + try { + mkdirSync(fallback, { recursive: true }); + return fallback; + } catch { + return tmpdir(); + } + } +} + +function isPathLike(value: string): boolean { + return value.includes("/") || value.includes("\\") || value.startsWith("."); +} + +function pathCandidates(runtime: SemanticDiffRuntime): SemCandidate[] { + if (runtime.platform === "win32") { + const pathext = (runtime.env.PATHEXT || ".EXE;.CMD;.BAT;.COM") + .split(";") + .map((ext) => ext.trim()) + .filter(Boolean); + for (const dir of (runtime.env.PATH || "").split(runtime.pathDelimiter)) { + for (const ext of pathext) { + const candidate = join(dir, `sem${ext.toLowerCase()}`); + if (runtime.fileExists(candidate)) { + return [{ command: candidate, source: "path", explicit: false }]; + } + } + } + return []; + } + + return [{ command: "sem", source: "path", explicit: false }]; +} + +function semCandidates(runtime: SemanticDiffRuntime): SemCandidate[] { + const candidates: SemCandidate[] = []; + const explicit = runtime.env.PLANNOTATOR_SEM_PATH?.trim(); + + if (explicit) { + candidates.push({ command: explicit, source: "env", explicit: true }); + return candidates; + } + + const managed = getManagedSemBinaryPath(runtime.dataDir, runtime.platform); + if (runtime.fileExists(managed)) { + candidates.push({ command: managed, source: "managed", explicit: false }); + } + + candidates.push(...pathCandidates(runtime)); + return candidates; +} + +export function parseSemVersion(stdout: string): string | null { + const match = stdout.trim().match(/^sem\s+([0-9]+(?:\.[0-9]+){1,3}(?:[-+][^\s]+)?)/); + return match?.[1] ?? null; +} + +async function resolveSem(runtime: SemanticDiffRuntime): Promise { + for (const candidate of semCandidates(runtime)) { + if (candidate.explicit && isPathLike(candidate.command) && !runtime.fileExists(candidate.command)) { + return { + status: "unavailable", + reason: "sem-path-missing", + message: `PLANNOTATOR_SEM_PATH points to a missing file: ${candidate.command}`, + }; + } + + const versionResult = await runtime.runCommand(candidate.command, ["--version"], { + timeoutMs: SEM_VERSION_TIMEOUT_MS, + }); + const version = parseSemVersion(versionResult.stdout); + if (versionResult.exitCode === 0 && version) { + return { command: candidate.command, source: candidate.source, version }; + } + + if (candidate.explicit) { + return { + status: "unavailable", + reason: "invalid-sem-binary", + message: `PLANNOTATOR_SEM_PATH did not resolve to the Ataraxy sem CLI.`, + }; + } + } + + return { + status: "unavailable", + reason: "sem-not-found", + message: "Semantic diff is unavailable because the Ataraxy sem CLI was not found.", + }; +} + +export async function getSemanticDiffAvailability( + runtime: SemanticDiffRuntime = createDefaultSemanticDiffRuntime(), +): Promise { + const resolved = await resolveSem(runtime); + if ("command" in resolved) { + return { + available: true, + semVersion: resolved.version, + semSource: resolved.source, + }; + } + + return { + available: false, + reason: resolved.reason, + message: resolved.message, + }; +} + +function valueAsNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function valueAsString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function valueAsBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function summaryFromJson(value: unknown): SemanticDiffSummary { + const summary = value && typeof value === "object" ? value as Record : {}; + return { + fileCount: valueAsNumber(summary.fileCount) ?? 0, + added: valueAsNumber(summary.added) ?? 0, + modified: valueAsNumber(summary.modified) ?? 0, + deleted: valueAsNumber(summary.deleted) ?? 0, + moved: valueAsNumber(summary.moved) ?? 0, + renamed: valueAsNumber(summary.renamed) ?? 0, + reordered: valueAsNumber(summary.reordered) ?? 0, + binary: valueAsNumber(summary.binary) ?? 0, + orphan: valueAsNumber(summary.orphan) ?? 0, + total: valueAsNumber(summary.total) ?? 0, + }; +} + +function changeFromJson(value: unknown): SemanticDiffChange | null { + if (!value || typeof value !== "object") return null; + const change = value as Record; + const changeType = valueAsString(change.changeType); + const entityType = valueAsString(change.entityType); + const entityName = valueAsString(change.entityName); + const filePath = valueAsString(change.filePath); + if (!changeType || !entityType || !entityName || !filePath) return null; + + return { + entityId: valueAsString(change.entityId), + changeType, + entityType, + entityName, + oldEntityName: valueAsString(change.oldEntityName), + filePath, + oldFilePath: valueAsString(change.oldFilePath), + startLine: valueAsNumber(change.startLine), + endLine: valueAsNumber(change.endLine), + oldStartLine: valueAsNumber(change.oldStartLine), + oldEndLine: valueAsNumber(change.oldEndLine), + structuralChange: valueAsBoolean(change.structuralChange), + }; +} + +function binaryChangeFromJson(value: unknown): SemanticDiffBinaryChange | null { + if (!value || typeof value !== "object") return null; + const change = value as Record; + const filePath = valueAsString(change.filePath); + if (!filePath) return null; + return { + changeType: "binary", + filePath, + oldFilePath: valueAsString(change.oldFilePath), + fileStatus: valueAsString(change.fileStatus), + }; +} + +export function parseSemanticDiffJson(stdout: string, sem: ResolvedSem): SemanticDiffResponse { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return { + status: "error", + reason: "invalid-json", + message: "sem returned invalid JSON.", + semVersion: sem.version, + semSource: sem.source, + }; + } + + if (!parsed || typeof parsed !== "object") { + return { + status: "error", + reason: "invalid-json-shape", + message: "sem returned an unexpected JSON payload.", + semVersion: sem.version, + semSource: sem.source, + }; + } + + const payload = parsed as Record; + const changes = Array.isArray(payload.changes) + ? payload.changes.map(changeFromJson).filter((change): change is SemanticDiffChange => !!change) + : []; + const binaryChanges = Array.isArray(payload.binaryChanges) + ? payload.binaryChanges.map(binaryChangeFromJson).filter((change): change is SemanticDiffBinaryChange => !!change) + : []; + + return { + status: "ok", + summary: summaryFromJson(payload.summary), + changes, + binaryChanges, + semVersion: sem.version, + semSource: sem.source, + }; +} + +export function normalizeSemanticDiffFileExts(fileExts: string[] | undefined): string[] { + return Array.from(new Set((fileExts ?? []) + .map((ext) => ext.trim()) + .filter(Boolean) + .map((ext) => ext.startsWith(".") ? ext : `.${ext}`))); +} + +export function semanticDiffFileExtsFromSearchParams(params: URLSearchParams): string[] { + const requested = [ + ...params.getAll("fileExt"), + ...params.getAll("fileExts").flatMap((value) => value.split(",")), + ]; + return normalizeSemanticDiffFileExts(requested); +} + +export function semanticDiffCacheKey(input: { + rawPatch: string; + cwd?: string; + fileExts?: string[]; +}): string { + const hash = createHash("sha256"); + hash.update(input.rawPatch); + hash.update("\0"); + hash.update(input.cwd ?? ""); + hash.update("\0"); + hash.update(normalizeSemanticDiffFileExts(input.fileExts).join("\0")); + return hash.digest("hex"); +} + +export class SemanticDiffResponseCache { + private readonly cache = new Map(); + private rawPatch: string | null = null; + + constructor(private readonly maxEntries = 8) {} + + get(cacheKey: string, rawPatch: string): SemanticDiffResponse | undefined { + this.syncPatch(rawPatch); + return this.cache.get(cacheKey); + } + + set(cacheKey: string, rawPatch: string, response: SemanticDiffResponse): void { + this.syncPatch(rawPatch); + + if (!this.cache.has(cacheKey) && this.cache.size >= this.maxEntries) { + const oldestKey = this.cache.keys().next().value; + if (typeof oldestKey === "string") { + this.cache.delete(oldestKey); + } + } + + this.cache.set(cacheKey, response); + } + + private syncPatch(rawPatch: string): void { + if (this.rawPatch === rawPatch) return; + this.cache.clear(); + this.rawPatch = rawPatch; + } +} + +export async function runSemanticDiff( + options: { + rawPatch: string; + cwd?: string; + fileExts?: string[]; + timeoutMs?: number; + }, + runtime: SemanticDiffRuntime = createDefaultSemanticDiffRuntime(), +): Promise { + if (!options.rawPatch.trim()) { + return { + status: "ok", + summary: { + fileCount: 0, + added: 0, + modified: 0, + deleted: 0, + moved: 0, + renamed: 0, + reordered: 0, + binary: 0, + orphan: 0, + total: 0, + }, + changes: [], + binaryChanges: [], + semVersion: "not-run", + semSource: "empty-patch", + }; + } + + const cwd = options.cwd || runtime.cwd || getSemanticDiffScratchCwd(runtime.dataDir); + const effectiveRuntime = cwd === runtime.cwd ? runtime : { ...runtime, cwd }; + const resolved = await resolveSem(effectiveRuntime); + if (!("command" in resolved)) return resolved; + + const fileExts = normalizeSemanticDiffFileExts(options.fileExts); + const args = ["diff", "--patch", "--format", "json"]; + if (fileExts.length > 0) { + args.push("--file-exts", ...fileExts); + } + + const result = await effectiveRuntime.runCommand(resolved.command, args, { + cwd, + input: options.rawPatch, + timeoutMs: options.timeoutMs ?? SEM_TIMEOUT_MS, + }); + + if (result.timedOut) { + return { + status: "error", + reason: "sem-timeout", + message: result.error ?? "sem timed out while analyzing the diff.", + semVersion: resolved.version, + semSource: resolved.source, + }; + } + + if (result.exitCode !== 0) { + return { + status: "error", + reason: "sem-exit", + message: result.stderr.trim() || result.error || `sem exited with code ${result.exitCode}.`, + exitCode: result.exitCode, + stderr: result.stderr.trim() || undefined, + semVersion: resolved.version, + semSource: resolved.source, + }; + } + + return parseSemanticDiffJson(result.stdout, resolved); +} diff --git a/scripts/install.cmd b/scripts/install.cmd index 66d83c2e..b9a47bb6 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -134,6 +134,8 @@ goto parse_args :args_done set "REPO=backnotprop/plannotator" +set "SEM_REPO=Ataraxy-Labs/sem" +set "SEM_VERSION=v0.8.0" set "INSTALL_DIR=%USERPROFILE%\.local\bin" REM First plannotator release that carries SLSA build-provenance attestations. @@ -397,6 +399,8 @@ move /y "!TEMP_FILE!" "!INSTALL_PATH!" >nul echo. echo plannotator !TAG! installed to !INSTALL_PATH! +call :InstallSemSidecar + REM Check if install directory is in PATH echo !PATH! | findstr /i /c:"!INSTALL_DIR!" >nul if !ERRORLEVEL! neq 0 ( @@ -1003,6 +1007,108 @@ if exist "!PLUGIN_HOOKS!" if exist "!CLAUDE_SETTINGS!" ( echo. exit /b 0 +REM ====================================================================== +REM Optional semantic diff sidecar install. Non-fatal: Plannotator remains +REM installed if sem download, checksum, or extraction fails. +REM ====================================================================== +:InstallSemSidecar +if /i "!PLANNOTATOR_SKIP_SEM_INSTALL!"=="1" ( + echo Skipping semantic diff sidecar install ^(PLANNOTATOR_SKIP_SEM_INSTALL is set^) + goto :eof +) +if /i "!PLANNOTATOR_SKIP_SEM_INSTALL!"=="true" ( + echo Skipping semantic diff sidecar install ^(PLANNOTATOR_SKIP_SEM_INSTALL is set^) + goto :eof +) +if /i "!PLANNOTATOR_SKIP_SEM_INSTALL!"=="yes" ( + echo Skipping semantic diff sidecar install ^(PLANNOTATOR_SKIP_SEM_INSTALL is set^) + goto :eof +) + +set "SEM_ASSET=" +if /i "!PLATFORM!"=="win32-x64" set "SEM_ASSET=sem-windows-x86_64.zip" +if not defined SEM_ASSET ( + echo Skipping semantic diff sidecar install ^(sem does not publish !PLATFORM!^) + goto :eof +) + +set "SEM_DIR=!_CONFIG_DIR!\vendor\sem\!SEM_VERSION!" +set "SEM_PATH=!SEM_DIR!\sem.exe" +if exist "!SEM_PATH!" ( + "!SEM_PATH!" --version 2>nul | findstr /r /c:"^sem " >nul 2>&1 + if !ERRORLEVEL! equ 0 ( + echo Semantic diff sidecar already installed at !SEM_PATH! + goto :eof + ) +) + +set "SEM_BASE_URL=https://github.com/!SEM_REPO!/releases/download/!SEM_VERSION!" +set "SEM_ARCHIVE=%TEMP%\plannotator-sem-%RANDOM%.zip" +set "SEM_CHECKSUMS=%TEMP%\plannotator-sem-checksums-%RANDOM%.txt" +set "SEM_EXTRACT=%TEMP%\plannotator-sem-%RANDOM%" +mkdir "!SEM_EXTRACT!" >nul 2>&1 + +curl -fsSL "!SEM_BASE_URL!/!SEM_ASSET!" -o "!SEM_ARCHIVE!" +if !ERRORLEVEL! neq 0 ( + echo Skipping semantic diff sidecar install ^(download failed^) + goto :sem_cleanup +) + +curl -fsSL "!SEM_BASE_URL!/checksums.txt" -o "!SEM_CHECKSUMS!" +if !ERRORLEVEL! neq 0 ( + echo Skipping semantic diff sidecar install ^(checksum download failed^) + goto :sem_cleanup +) + +set "EXPECTED_SEM_CHECKSUM=" +for /f "usebackq tokens=1,2" %%i in ("!SEM_CHECKSUMS!") do ( + if "%%j"=="!SEM_ASSET!" set "EXPECTED_SEM_CHECKSUM=%%i" +) +if not defined EXPECTED_SEM_CHECKSUM ( + echo Skipping semantic diff sidecar install ^(checksum missing for !SEM_ASSET!^) + goto :sem_cleanup +) + +set "ACTUAL_SEM_CHECKSUM=" +for /f "skip=1 tokens=*" %%i in ('certutil -hashfile "!SEM_ARCHIVE!" SHA256') do ( + if not defined ACTUAL_SEM_CHECKSUM ( + set "ACTUAL_SEM_CHECKSUM=%%i" + set "ACTUAL_SEM_CHECKSUM=!ACTUAL_SEM_CHECKSUM: =!" + ) +) +if /i "!ACTUAL_SEM_CHECKSUM!" neq "!EXPECTED_SEM_CHECKSUM!" ( + echo Skipping semantic diff sidecar install ^(checksum mismatch^) + goto :sem_cleanup +) + +powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -Force -Path $env:SEM_ARCHIVE -DestinationPath $env:SEM_EXTRACT" +if !ERRORLEVEL! neq 0 ( + echo Skipping semantic diff sidecar install ^(extract failed^) + goto :sem_cleanup +) +set "EXTRACTED_SEM=" +for /f "usebackq delims=" %%i in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-ChildItem -Path $env:SEM_EXTRACT -Filter sem.exe -Recurse -File | Select-Object -First 1 -ExpandProperty FullName"`) do ( + set "EXTRACTED_SEM=%%i" +) +if not defined EXTRACTED_SEM ( + echo Skipping semantic diff sidecar install ^(binary missing from archive^) + goto :sem_cleanup +) + +if not exist "!SEM_DIR!" mkdir "!SEM_DIR!" +copy /y "!EXTRACTED_SEM!" "!SEM_PATH!" >nul +if !ERRORLEVEL! equ 0 ( + echo Semantic diff sidecar installed to !SEM_PATH! +) else ( + echo Skipping semantic diff sidecar install ^(copy failed^) +) + +:sem_cleanup +if exist "!SEM_ARCHIVE!" del "!SEM_ARCHIVE!" +if exist "!SEM_CHECKSUMS!" del "!SEM_CHECKSUMS!" +if exist "!SEM_EXTRACT!" rmdir /s /q "!SEM_EXTRACT!" +goto :eof + REM ====================================================================== REM Guided-install wizard (called only on interactive first runs or with REM --reconfigure). Sets EXTRAS_CHOICE and INVOCABLE_CHOICE. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ad25061b..a1ed1854 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -31,6 +31,8 @@ if ($Glimpse -and $NoGlimpse) { } $repo = "backnotprop/plannotator" +$semRepo = "Ataraxy-Labs/sem" +$semVersion = "v0.8.0" $installDir = "$env:LOCALAPPDATA\plannotator" # First plannotator release that carries SLSA build-provenance attestations. @@ -114,6 +116,72 @@ if ($configDir -eq "~") { } elseif ($configDir.StartsWith("~/") -or $configDir.StartsWith('~\')) { $configDir = Join-Path $env:USERPROFILE ($configDir.Substring(2)) } + +function Install-SemSidecar { + if ($env:PLANNOTATOR_SKIP_SEM_INSTALL -match '^(1|true|yes)$') { + Write-Host "Skipping semantic diff sidecar install (PLANNOTATOR_SKIP_SEM_INSTALL is set)" + return + } + + $semAsset = if ($platform -eq "win32-x64") { "sem-windows-x86_64.zip" } else { $null } + if (-not $semAsset) { + Write-Host "Skipping semantic diff sidecar install (sem does not publish $platform)" + return + } + + $semDir = Join-Path $configDir "vendor\sem\$semVersion" + $semPath = Join-Path $semDir "sem.exe" + if (Test-Path $semPath) { + try { + $versionText = & $semPath --version 2>$null + if ($LASTEXITCODE -eq 0 -and $versionText -match '^sem ') { + Write-Host "Semantic diff sidecar already installed at $semPath" + return + } + } catch { + # Replace invalid stale sidecar below. + } + } + + $tmpSemDir = Join-Path ([System.IO.Path]::GetTempPath()) "plannotator-sem-$([System.Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Force -Path $tmpSemDir | Out-Null + + try { + $semBaseUrl = "https://github.com/$semRepo/releases/download/$semVersion" + $semArchive = Join-Path $tmpSemDir $semAsset + $semChecksums = Join-Path $tmpSemDir "checksums.txt" + Invoke-WebRequest -Uri "$semBaseUrl/$semAsset" -OutFile $semArchive -UseBasicParsing + Invoke-WebRequest -Uri "$semBaseUrl/checksums.txt" -OutFile $semChecksums -UseBasicParsing + + $expected = (Get-Content $semChecksums | Where-Object { $_ -match "\s$([regex]::Escape($semAsset))$" } | ForEach-Object { ($_ -split '\s+')[0] } | Select-Object -First 1) + if (-not $expected) { + Write-Host "Skipping semantic diff sidecar install (checksum missing for $semAsset)" + return + } + + $actual = (Get-FileHash -Path $semArchive -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected.ToLower()) { + Write-Host "Skipping semantic diff sidecar install (checksum mismatch)" + return + } + + Expand-Archive -Force -Path $semArchive -DestinationPath $tmpSemDir + $extracted = Get-ChildItem -Path $tmpSemDir -Filter "sem.exe" -Recurse | Select-Object -First 1 + if (-not $extracted) { + Write-Host "Skipping semantic diff sidecar install (binary missing from archive)" + return + } + + New-Item -ItemType Directory -Force -Path $semDir | Out-Null + Copy-Item -Force $extracted.FullName $semPath + Write-Host "Semantic diff sidecar installed to $semPath" + } catch { + Write-Host "Skipping semantic diff sidecar install ($($_.Exception.Message))" + } finally { + Remove-Item -Recurse -Force $tmpSemDir -ErrorAction SilentlyContinue + } +} + $configPath = Join-Path $configDir "config.json" if (Test-Path $configPath) { try { @@ -250,6 +318,8 @@ Move-Item -Force $tmpFile "$installDir\plannotator.exe" Write-Host "" Write-Host "plannotator $latestTag installed to $installDir\plannotator.exe" +Install-SemSidecar + # Add to PATH if not already there $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($userPath -notlike "*$installDir*") { diff --git a/scripts/install.sh b/scripts/install.sh index 5c830765..42c61373 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,6 +2,8 @@ set -e REPO="backnotprop/plannotator" +SEM_REPO="Ataraxy-Labs/sem" +SEM_VERSION="v0.8.0" INSTALL_DIR="$HOME/.local/bin" # First plannotator release that carries SLSA build-provenance attestations. @@ -379,6 +381,106 @@ chmod +x "$INSTALL_DIR/plannotator" echo "" echo "plannotator ${latest_tag} installed to ${INSTALL_DIR}/plannotator" +sem_asset_for_platform() { + case "$platform" in + darwin-arm64) echo "sem-darwin-arm64.tar.gz" ;; + linux-arm64) echo "sem-linux-arm64.tar.gz" ;; + linux-x64) echo "sem-linux-x86_64.tar.gz" ;; + *) return 1 ;; + esac +} + +install_sem_sidecar() { + case "${PLANNOTATOR_SKIP_SEM_INSTALL:-}" in + 1|true|yes|TRUE|YES|True|Yes) + echo "Skipping semantic diff sidecar install (PLANNOTATOR_SKIP_SEM_INSTALL is set)" + return 0 + ;; + esac + + sem_asset="$(sem_asset_for_platform 2>/dev/null || true)" + if [ -z "$sem_asset" ]; then + echo "Skipping semantic diff sidecar install (sem does not publish ${platform})" + return 0 + fi + + sem_dir="${_config_dir}/vendor/sem/${SEM_VERSION}" + sem_bin="${sem_dir}/sem" + if [ -x "$sem_bin" ] && "$sem_bin" --version 2>/dev/null | grep -q '^sem '; then + echo "Semantic diff sidecar already installed at ${sem_bin}" + return 0 + fi + + tmp_sem_dir="$(mktemp -d)" + sem_archive="${tmp_sem_dir}/${sem_asset}" + sem_checksums="${tmp_sem_dir}/checksums.txt" + sem_base_url="https://github.com/${SEM_REPO}/releases/download/${SEM_VERSION}" + + if ! curl -fsSL -o "$sem_archive" "${sem_base_url}/${sem_asset}"; then + echo "Skipping semantic diff sidecar install (download failed)" + rm -rf "$tmp_sem_dir" + return 0 + fi + if ! curl -fsSL -o "$sem_checksums" "${sem_base_url}/checksums.txt"; then + echo "Skipping semantic diff sidecar install (checksum download failed)" + rm -rf "$tmp_sem_dir" + return 0 + fi + + expected_sem_checksum="$(awk -v name="$sem_asset" '$2 == name { print $1 }' "$sem_checksums")" + if [ -z "$expected_sem_checksum" ]; then + echo "Skipping semantic diff sidecar install (checksum missing for ${sem_asset})" + rm -rf "$tmp_sem_dir" + return 0 + fi + + if [ "$(uname -s)" = "Darwin" ]; then + actual_sem_checksum="$(shasum -a 256 "$sem_archive" | cut -d' ' -f1)" + else + actual_sem_checksum="$(sha256sum "$sem_archive" | cut -d' ' -f1)" + fi + + if [ "$actual_sem_checksum" != "$expected_sem_checksum" ]; then + echo "Skipping semantic diff sidecar install (checksum mismatch)" + rm -rf "$tmp_sem_dir" + return 0 + fi + + if ! tar -xzf "$sem_archive" -C "$tmp_sem_dir"; then + echo "Skipping semantic diff sidecar install (extract failed)" + rm -rf "$tmp_sem_dir" + return 0 + fi + + extracted_sem="$(find "$tmp_sem_dir" -type f -name sem -print -quit)" + if [ -z "$extracted_sem" ]; then + echo "Skipping semantic diff sidecar install (binary missing from archive)" + rm -rf "$tmp_sem_dir" + return 0 + fi + + if ! mkdir -p "$sem_dir"; then + echo "Skipping semantic diff sidecar install (directory creation failed)" + rm -rf "$tmp_sem_dir" + return 0 + fi + if ! cp "$extracted_sem" "$sem_bin"; then + echo "Skipping semantic diff sidecar install (copy failed)" + rm -rf "$tmp_sem_dir" + return 0 + fi + if ! chmod +x "$sem_bin"; then + echo "Skipping semantic diff sidecar install (chmod failed)" + rm -f "$sem_bin" + rm -rf "$tmp_sem_dir" + return 0 + fi + rm -rf "$tmp_sem_dir" + echo "Semantic diff sidecar installed to ${sem_bin}" +} + +install_sem_sidecar + if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then echo "" echo "${INSTALL_DIR} is not in your PATH. Add it with:" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 85074cc0..04bebd90 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -972,6 +972,33 @@ describe("install shared behavior", () => { expect(cmdScript).toContain("predates"); }); + test("all installers install sem sidecar as a non-fatal optional dependency", () => { + const cmdScript = readFileSync(join(scriptsDir, "install.cmd"), "utf-8"); + + expect(sh).toContain('SEM_REPO="Ataraxy-Labs/sem"'); + expect(sh).toContain('SEM_VERSION="v0.8.0"'); + expect(sh).toContain("install_sem_sidecar"); + expect(sh).toContain("Skipping semantic diff sidecar install"); + expect(sh).toContain('${_config_dir}/vendor/sem/${SEM_VERSION}'); + expect(sh).toContain('if ! mkdir -p "$sem_dir"; then'); + expect(sh).toContain('if ! cp "$extracted_sem" "$sem_bin"; then'); + expect(sh).toContain('if ! chmod +x "$sem_bin"; then'); + + expect(ps).toContain('$semRepo = "Ataraxy-Labs/sem"'); + expect(ps).toContain('$semVersion = "v0.8.0"'); + expect(ps).toContain("function Install-SemSidecar"); + expect(ps).toContain('if ($platform -eq "win32-x64")'); + expect(ps).toContain("Skipping semantic diff sidecar install"); + + expect(cmdScript).toContain('set "SEM_REPO=Ataraxy-Labs/sem"'); + expect(cmdScript).toContain('set "SEM_VERSION=v0.8.0"'); + expect(cmdScript).toContain("call :InstallSemSidecar"); + expect(cmdScript).toContain('if /i "!PLATFORM!"=="win32-x64" set "SEM_ASSET=sem-windows-x86_64.zip"'); + expect(cmdScript).toContain("Skipping semantic diff sidecar install"); + expect(cmdScript).toContain("Get-ChildItem -Path $env:SEM_EXTRACT -Filter sem.exe -Recurse -File"); + expect(cmdScript).toContain('copy /y "!EXTRACTED_SEM!" "!SEM_PATH!"'); + }); + test("install.sh and help text use vX.Y.Z placeholder not v0.17.1", () => { // Regression guard: the docs and --help text previously used v0.17.1 // as a concrete pinned-version example. That tag predates provenance