mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
6ec1a66c9b
* feat(review): large GitHub PR fallback + non-blocking PR checkout
Two PR-mode improvements:
1. Large GitHub PRs no longer fail to load. When `gh pr diff` is refused
(HTTP 406 for oversized diffs), fetchGhPR pages through the pulls files
API and stitches the per-file patches into a unified diff — mirroring
the existing GitLab raw_diffs fallback. Path quoting matches git's
exact rules (bare spaces unquoted) so downstream parsers round-trip;
truncation at the API's 3000-file cap is surfaced, never silent.
2. The --local worktree/clone no longer blocks startup. The review server
opens as soon as the platform diff arrives; the checkout warms in the
background as a seeded not-ready pool entry. Consumers that need real
files (agent jobs, full-stack diff, code-nav, semantic diff, AI
sessions) await pool.ensure(), with creations serialized so concurrent
fetches can't clobber the shared FETCH_HEAD. Cross-repo clone steps
converted from spawnSync to async spawns; warmup children are killed
on exit (plus `git worktree prune`) so aborted sessions can't leak
stale registrations; failed checkouts degrade honestly (no agent runs
in the wrong directory claiming local access) with a 30s retry
cooldown.
* fix(review): survive long PR checkout warmups + classify reconstructed renames
Stress-testing against oven-sh/bun#30412 (2,188 files) surfaced three bugs:
- Bun.serve's default 10s idleTimeout killed /api/semantic-diff while it
parked on the background checkout warmup (a clone that can take minutes).
Disable the idle timeout on all servers — AI SSE streams can also stall
>10s between bytes while a permission prompt waits.
- The file-badge hook memoized that failed fetch in a module-level cache
keyed by patch, pinning every badge to empty until a hard refresh. Never
cache failures; retry with backoff (5s/15s/30s).
- reconstructGhPatch/reconstructPatch omitted the `similarity index` line,
which Pierre's parser keys rename classification off — pure renames
rendered as blank plain changes with no old path. Emit 100% for
patch-less renames/copies (exactly accurate) and a synthetic 99% for
patched ones (consumers only branch on 100% vs not).
* feat(review): local full-diff upgrade for PRs whose API diff is truncated
On oversized PRs the platform APIs withhold per-file patch content entirely
(bun#30412: 1,066 of 2,188 files came back with status added/modified, zeroed
counts, and no patch). Those files rendered as empty stubs with no diff.
- fetchGhPR/fetchGlMR flag the result `patchIncomplete` when patch-less
non-rename entries exist or the 3000-file cap truncates the listing.
- New runPRLayerLocalDiff (pr-stack.ts) recomputes the exact layer diff in
the local checkout: platform merge-base + head SHA two-dot diff (three-dot
vs baseSha fallback), fetch-by-SHA for objects missing from shallow clones,
-l0 so rename detection doesn't silently degrade on huge PRs.
- The review UI shows a "Partial diff · Load full diff" notice in layer
scope; clicking re-requests the layer scope and the server swaps in the
recomputed full diff (waiting out the background clone if needed).
- PR scope/switch state writes are epoch-guarded: a request parked on the
checkout warmup can no longer overwrite a newer scope select or pr-switch.
- draftKey follows the upgraded patch so annotation drafts survive pr-switch
round-trips; recompute failures surface in the response error field.
- Pi server mirrors all of it, including an agentCwd fallback so the upgrade
works for PRs switched-to under a cross-repo clone pool.
* fix(review): use GitLab's too_large/collapsed flags for withheld-diff detection
External review caught a false negative: a too-large ADDED file comes back
new_file:true with an empty diff — indistinguishable from a legitimately
empty new file under the old heuristic, so the partial-diff upgrade was
never offered for exactly the files that matter most on big MRs.
The REST /diffs endpoint marks withheld content explicitly per entry
(verified against gitlab.com): too_large/collapsed are now authoritative in
both directions — withheld adds/deletes are flagged, binaries and empty
files are never misflagged. Older GitLab without the fields keeps the
empty-diff-on-modification heuristic.
* feat(prompts): unify review-denied suffix — triage first, no coding off raw feedback
The per-runtime defaults map (#627) gave OpenCode and Pi a different
review-denied suffix than every other runtime; updating one meant the
others silently kept "you must address all of them" — an instruction to
start coding immediately. Claude Code, Amp, Droid, Codex, Copilot, Gemini,
and Kiro were all still on it.
One default for every runtime now: triage the feedback, verify it against
the code, discuss before changing anything. Per-runtime customization
remains available via config (prompts.review.runtimes.<rt>.denied), which
resolves above the built-in default as before.
* fix(prompts): generalize review-denied suffix — 'from review', not 'external AI reviewers'
Review feedback isn't always from AI reviewers or agent jobs; often it's
the human reviewer's own annotations. Neutral wording covers both.
* fix(review): non-blocking 'Load full diff' + flag-handling hardenings
Self-review findings:
- The partial-diff upgrade reused the scope-switch handler, so clicking
"Load full diff" raised the full-screen PRSwitchOverlay — blocking the
entire UI, potentially for minutes behind a cold clone, with no text and
no cancel. The upgrade now has its own loading state: the notice shows a
spinner ("Loading full diff…") and the reviewer keeps working with the
partial diff while the request parks. Server-side epoch guards already
handle scope/PR changes made during the wait.
- GitLab too_large/collapsed: treat explicit null like absent (flags
inconclusive → legacy heuristic decides) instead of silently exonerating.
- Rename-limit lift uses -l100000 instead of -l0 ("0 = unlimited" only
holds on git >= 2.29; on older git it could disable detection outright).
* fix(review): stop scroll-driven sem stampede when semantic diff is failing
The badge retry change (a2d19a4e) cleared the client-side sem cache on
failure so transient errors could recover. But file-header badges mount and
unmount on every scroll in the virtualized all-files view, and each mount
re-requests /api/semantic-diff — and the server only cached SUCCESSFUL runs.
With sem erroring, scrolling spawned a continuous stream of sem processes,
pegging the CPU and making scrolling severely choppy.
Bound retry rate by time, not by mount events:
- client: keep the failed result memoized and expire it after a 60s
cooldown instead of clearing immediately
- server (Bun + Pi): memoize failed sem runs for 30s in
SemanticDiffResponseCache — request rate can no longer drive execution
rate
* fix(review): eliminate all-files scroll jank (pre-existing on main, from #885)
The CodeView migration introduced severe scroll chop; scrolling UP could
freeze the viewport entirely ("scrolling but nothing changes"). Three
compounding causes, diagnosed against Pierre 1.2.8 source:
1. Lazy full-content augmentation landed updateItem() mid-scroll-gesture:
the full-content parse counts collapsed-context regions the raw-patch
parse doesn't, so the item GROWS — re-render + re-tokenize hitches both
directions, and when the grown item sat above CodeView's scroll anchor,
its corrective scrollTo() killed wheel momentum (the up-scroll freeze).
Fetches still start as items enter the window; the item mutation now
waits for 150ms of scroll quiet (staleness re-checked at apply time).
2. reportVisibleFile read container.scrollTop/clientHeight/scrollHeight on
EVERY scroll event — a forced synchronous layout right after each
frame's DOM writes. Replaced with CodeView's cached accessors and
coalesced the handler to once per animation frame.
3. Missing containment CSS: Pierre's own production wrapper uses
contain:strict + will-change:scroll-position so forced layouts stay
scoped to the scroller instead of the whole document. Adopted.
Also: __devOnlyValidateItemHeights now requires explicit opt-in
(VITE_PIERRE_VALIDATE_HEIGHTS=1) — it runs getBoundingClientRect() per
rendered item per frame and made dev-server scrolling choppy by itself.
* feat(review): change-type status in headers + tree, diffshub CSS parity
Adopts two diffshub practices identified in the architecture comparison:
- DiffFile now carries a derived status (added/deleted/renamed/modified)
from the chunk's git metadata lines. FileHeader shows a status icon and
renders renames as "old/path → new/path" (dimmed old, arrow — diffshub's
treatment, including its rename blue); the file tree shows A/D/R markers.
'modified' is deliberately undecorated so the others pop. Works in both
the all-files surface and the single-file panel, including header-only
pure renames from the large-PR reconstruction.
- CodeView container gains diffshub's remaining perf CSS: overflow-anchor:
none (native scroll anchoring fights CodeView's own anchor resolution
whenever item heights change — exactly our augmentation applies),
overflow-x-clip, and overflow-clip containment on item elements.
* feat(review): worker-pool syntax highlighting (diffshub parity)
A performance trace of scrolling a small local diff attributed 2.2s of
2.6s main-thread CPU to findNextMatchSync — shiki's TextMate regex
scanner tokenizing on the main thread. diffshub avoids this entirely by
running tokenization in Pierre's worker pool; we never opted in.
Wires WorkerPoolContextProvider around the review app (pool size
min(cores-1, 3), 100-entry AST LRU, common languages preloaded), gates
the all-files surface on pool readiness with a 5s escape hatch (a dead
pool degrades to plaintext-then-highlight, never a blank view), and
syncs the UI theme pair into the long-lived pool.
Single-file build constraint solved with Vite's ?worker&inline (base64
blob worker) + worker.format 'es' with inlineDynamicImports — the
worker's lazy import("shiki/wasm") branch collapses into the bundle and
is never taken (shiki-js engine: the win is moving work off the main
thread, with no .wasm asset to smuggle into one HTML file). Bundle
+850KB.
* fix(review): un-poison worker-pool theme dedup on failed setRenderOptions
A failed round-trip recorded the theme as synced and never retried,
pinning the pool to the wrong palette for the session.
* fix(review): report partial diffs without a checkout; fail fast on missing checkout
Dogfood review of this PR (via plannotator itself) caught two valid issues:
- prPatchIncomplete was gated on the worktree pool, so a --no-local session
showed a truncated diff with no indication at all. Partiality is
information; upgradability is a capability. The flag is now always
reported, with a separate prPatchUpgradeAvailable — the UI shows the
amber notice either way, with the "Load full diff" button only when a
checkout can exist (otherwise a "re-run with --local" hint).
- After a FAILED checkout warmup, Ask AI sessions and agent jobs fell back
to process.cwd() (or a wrong revision on Pi) — running in the wrong tree
instead of failing. Both launch points now refuse with a clear "Local
PR checkout unavailable — retry shortly" error (503); the job handlers
surface buildCommand refusals instead of mislabeling them "Invalid
JSON". Bun and Pi mirrored.
A third finding (sem availability stuck after warmup) was triaged invalid:
the availability probe detects the sem binary, which is cwd-independent.
* fix(review): runtime-neutral copy for the no-checkout partial-diff hint
--local is a CLI remedy; OpenCode sessions have no such flag. Visible
text states the fact, the tooltip carries the CLI guidance.
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
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<CommandResult>;
|
|
fileExists: (path: string) => boolean;
|
|
env: Record<string, string | undefined>;
|
|
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<SemanticDiffResponse, { status: "ok" }>;
|
|
|
|
function defaultRunCommand(
|
|
command: string,
|
|
args: string[],
|
|
options: { cwd?: string; input?: string; timeoutMs?: number } = {},
|
|
): Promise<CommandResult> {
|
|
return new Promise((resolveResult) => {
|
|
let settled = false;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let proc: ReturnType<typeof spawn>;
|
|
|
|
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<ResolvedSem | SemResolveFailure> {
|
|
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<SemanticDiffAvailability> {
|
|
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<string, unknown> : {};
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, SemanticDiffResponse>();
|
|
private readonly failures = new Map<string, { response: SemanticDiffResponse; expiresAt: number }>();
|
|
private rawPatch: string | null = null;
|
|
|
|
constructor(private readonly maxEntries = 8) {}
|
|
|
|
get(cacheKey: string, rawPatch: string): SemanticDiffResponse | undefined {
|
|
this.syncPatch(rawPatch);
|
|
const ok = this.cache.get(cacheKey);
|
|
if (ok) return ok;
|
|
const failed = this.failures.get(cacheKey);
|
|
if (failed) {
|
|
if (failed.expiresAt > Date.now()) return failed.response;
|
|
this.failures.delete(cacheKey);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
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);
|
|
this.failures.delete(cacheKey);
|
|
}
|
|
|
|
/**
|
|
* Memoize a FAILED run for a short window. Without this, every request for
|
|
* a failing (patch, cwd) re-executes sem — and the review UI's file badges
|
|
* re-request on every scroll-driven mount, so an erroring sem turns
|
|
* scrolling into a process stampede. The TTL keeps failures retryable
|
|
* without letting request rate drive execution rate.
|
|
*/
|
|
setFailure(cacheKey: string, rawPatch: string, response: SemanticDiffResponse, ttlMs = 30_000): void {
|
|
this.syncPatch(rawPatch);
|
|
this.failures.set(cacheKey, { response, expiresAt: Date.now() + ttlMs });
|
|
}
|
|
|
|
private syncPatch(rawPatch: string): void {
|
|
if (this.rawPatch === rawPatch) return;
|
|
this.cache.clear();
|
|
this.failures.clear();
|
|
this.rawPatch = rawPatch;
|
|
}
|
|
}
|
|
|
|
export async function runSemanticDiff(
|
|
options: {
|
|
rawPatch: string;
|
|
cwd?: string;
|
|
fileExts?: string[];
|
|
timeoutMs?: number;
|
|
},
|
|
runtime: SemanticDiffRuntime = createDefaultSemanticDiffRuntime(),
|
|
): Promise<SemanticDiffResponse> {
|
|
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);
|
|
}
|