mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
64062af9a1
A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI. Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor. Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
86 lines
3.1 KiB
TypeScript
86 lines
3.1 KiB
TypeScript
/**
|
|
* Unified diff → per-file records. The one splitter/path resolver shared by
|
|
* the review app, the guide chain, and the portable-format helpers
|
|
* (`listGuidePatchFiles`, language preload detection), so every consumer
|
|
* agrees on which files a patch contains.
|
|
*
|
|
* Browser-safe and dependency-free.
|
|
*/
|
|
|
|
import { parseDiffFilePathLines, parseDiffGitHeader, parseDiffMetadataPathLines } from "./diff-paths";
|
|
|
|
/**
|
|
* Change type derived from the diff's metadata lines. 'modified' is the
|
|
* default and is deliberately NOT decorated in the UI (mirroring Pierre's
|
|
* diffshub: most files are modifications, so only A/D/R stand out).
|
|
*/
|
|
export type DiffFileStatus = "added" | "deleted" | "renamed" | "modified";
|
|
|
|
/** One file of a unified diff, as the guide and the review app both consume it. */
|
|
export interface DiffFile {
|
|
path: string;
|
|
oldPath?: string;
|
|
patch: string;
|
|
additions: number;
|
|
deletions: number;
|
|
status: DiffFileStatus;
|
|
}
|
|
|
|
function splitDiffChunks(rawPatch: string): string[] {
|
|
const matches = [...rawPatch.matchAll(/^diff --git /gm)];
|
|
return matches.map((match, index) => {
|
|
const start = match.index ?? 0;
|
|
const end = matches[index + 1]?.index ?? rawPatch.length;
|
|
return rawPatch.slice(start, end);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Change type from the chunk's git metadata lines. Scans only the extended
|
|
* header (everything before the first hunk/--- line) so file content that
|
|
* happens to contain "rename from" etc. can't misclassify.
|
|
*/
|
|
function deriveStatus(lines: string[], oldPath: string, newPath: string): DiffFileStatus {
|
|
for (const line of lines) {
|
|
if (line.startsWith("@@") || line.startsWith("--- ") || line.startsWith("+++ ")) break;
|
|
if (line.startsWith("new file mode")) return "added";
|
|
if (line.startsWith("deleted file mode")) return "deleted";
|
|
if (line.startsWith("rename from ") || line.startsWith("copy from ")) return "renamed";
|
|
}
|
|
// Reconstructed/odd patches may carry distinct paths without rename lines.
|
|
return oldPath !== newPath ? "renamed" : "modified";
|
|
}
|
|
|
|
export function parseDiffToFiles(rawPatch: string): DiffFile[] {
|
|
const files: DiffFile[] = [];
|
|
|
|
for (const chunk of splitDiffChunks(rawPatch)) {
|
|
const lines = chunk.split("\n");
|
|
const fromFileLines = parseDiffFilePathLines(lines);
|
|
const fromMetadata = parseDiffMetadataPathLines(lines);
|
|
const fromHeader = parseDiffGitHeader(lines[0] ?? "");
|
|
const oldPath = fromFileLines.oldPath ?? fromFileLines.newPath ?? fromMetadata.oldPath ?? fromHeader.oldPath;
|
|
const newPath = fromFileLines.newPath ?? fromFileLines.oldPath ?? fromMetadata.newPath ?? fromHeader.newPath;
|
|
if (!oldPath || !newPath) continue;
|
|
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
|
|
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
|
|
}
|
|
|
|
files.push({
|
|
path: newPath,
|
|
oldPath: oldPath !== newPath ? oldPath : undefined,
|
|
patch: chunk,
|
|
additions,
|
|
deletions,
|
|
status: deriveStatus(lines, oldPath, newPath),
|
|
});
|
|
}
|
|
|
|
return files;
|
|
}
|