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
258 lines
9.5 KiB
TypeScript
258 lines
9.5 KiB
TypeScript
export interface DiffPathPair {
|
|
oldPath?: string;
|
|
newPath?: string;
|
|
}
|
|
|
|
/** Single-char C escapes git emits (see git's quote.c) mapped to their byte. */
|
|
const C_ESCAPES: Record<string, number> = {
|
|
'"': 0x22, "\\": 0x5c, a: 0x07, b: 0x08, f: 0x0c,
|
|
n: 0x0a, r: 0x0d, t: 0x09, v: 0x0b,
|
|
};
|
|
|
|
/**
|
|
* Undo git's C-style path quoting (core.quotePath): `"caf\303\251.txt"` →
|
|
* `café.txt`. Octal escapes are RAW BYTES of the UTF-8 encoded path —
|
|
* consecutive octal escapes are collected into a byte buffer and decoded as
|
|
* one UTF-8 sequence. JSON.parse cannot do this: octal escapes are invalid
|
|
* JSON, so non-ASCII names silently kept their literal `\303\251` form and
|
|
* broke every downstream file access.
|
|
*
|
|
* Literal (non-escaped) characters are appended as-is, NOT pushed through the
|
|
* byte decoder — our own quoteGitPath (JSON.stringify, used when synthesizing
|
|
* workspace patch headers) leaves unicode unescaped inside quotes, and those
|
|
* headers round-trip through this function too. Unquoted values pass through
|
|
* untouched.
|
|
*/
|
|
export function unquoteGitPath(value: string): string {
|
|
if (!value.startsWith('"') || !value.endsWith('"')) return value;
|
|
const inner = value.slice(1, -1);
|
|
const decoder = new TextDecoder();
|
|
let out = "";
|
|
let pendingBytes: number[] = [];
|
|
const flush = (): void => {
|
|
if (pendingBytes.length > 0) {
|
|
out += decoder.decode(new Uint8Array(pendingBytes));
|
|
pendingBytes = [];
|
|
}
|
|
};
|
|
for (let i = 0; i < inner.length; i++) {
|
|
if (inner[i] !== "\\") {
|
|
// Appending code units in order preserves any literal unicode,
|
|
// including surrogate pairs.
|
|
flush();
|
|
out += inner[i];
|
|
continue;
|
|
}
|
|
const next = inner[i + 1];
|
|
if (next >= "0" && next <= "7") {
|
|
// Octal escape: up to 3 digits, one raw byte of the UTF-8 path.
|
|
let oct = "";
|
|
let j = i + 1;
|
|
while (j < inner.length && oct.length < 3 && inner[j] >= "0" && inner[j] <= "7") {
|
|
oct += inner[j];
|
|
j++;
|
|
}
|
|
pendingBytes.push(parseInt(oct, 8) & 0xff);
|
|
i = j - 1;
|
|
} else if (next !== undefined && next in C_ESCAPES) {
|
|
flush();
|
|
out += String.fromCharCode(C_ESCAPES[next]);
|
|
i++;
|
|
} else if (next === "u" && /^[0-9a-fA-F]{4}$/.test(inner.slice(i + 2, i + 6))) {
|
|
// \uXXXX: never emitted by git, but our own quoteGitPath is
|
|
// JSON.stringify, which uses it for control chars lacking a short
|
|
// JSON escape (e.g. a vertical tab, \u000b) — synthesized workspace headers round-trip
|
|
// through here, so this must decode or the path keeps a literal
|
|
// backslash and file access breaks.
|
|
flush();
|
|
out += String.fromCharCode(parseInt(inner.slice(i + 2, i + 6), 16));
|
|
i += 5;
|
|
} else {
|
|
// Unknown escape — keep the backslash literally.
|
|
flush();
|
|
out += "\\";
|
|
}
|
|
}
|
|
flush();
|
|
return out;
|
|
}
|
|
|
|
export function quoteGitPath(value: string): string {
|
|
if (!/[\s"\\]/.test(value)) return value;
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function stripUnquotedPathMetadata(token: string): string {
|
|
if (token.startsWith('"')) return token;
|
|
const tabIndex = token.indexOf("\t");
|
|
return tabIndex === -1 ? token : token.slice(0, tabIndex);
|
|
}
|
|
|
|
export function parsePatchPathToken(token: string, side: "a" | "b"): string | null {
|
|
const pathToken = stripUnquotedPathMetadata(token);
|
|
if (pathToken === "/dev/null") return "/dev/null";
|
|
const unquoted = unquoteGitPath(pathToken);
|
|
const prefix = `${side}/`;
|
|
return unquoted.startsWith(prefix) ? unquoted.slice(prefix.length) : null;
|
|
}
|
|
|
|
function scanHeaderToken(input: string): { token: string; rest: string } | null {
|
|
const trimmed = input.trimStart();
|
|
if (!trimmed) return null;
|
|
|
|
if (trimmed.startsWith('"')) {
|
|
let escaped = false;
|
|
for (let i = 1; i < trimmed.length; i += 1) {
|
|
const char = trimmed[i];
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (char === '"') {
|
|
return { token: trimmed.slice(0, i + 1), rest: trimmed.slice(i + 1) };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const space = trimmed.indexOf(" ");
|
|
if (space === -1) return { token: trimmed, rest: "" };
|
|
return { token: trimmed.slice(0, space), rest: trimmed.slice(space + 1) };
|
|
}
|
|
|
|
export function parseDiffGitHeader(header: string): DiffPathPair {
|
|
const prefix = "diff --git ";
|
|
if (!header.startsWith(prefix)) return {};
|
|
|
|
const rest = header.slice(prefix.length);
|
|
if (rest.trimStart().startsWith('"')) {
|
|
const first = scanHeaderToken(rest);
|
|
const second = first ? scanHeaderToken(first.rest) : null;
|
|
if (first && second) {
|
|
const oldPath = parsePatchPathToken(first.token, "a");
|
|
const newPath = parsePatchPathToken(second.token, "b");
|
|
return {
|
|
oldPath: oldPath && oldPath !== "/dev/null" ? oldPath : undefined,
|
|
newPath: newPath && newPath !== "/dev/null" ? newPath : undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
const match = header.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
if (!match) return {};
|
|
return { oldPath: match[1], newPath: match[2] };
|
|
}
|
|
|
|
export function formatPatchPathToken(side: "a" | "b", filePath: string): string {
|
|
if (filePath === "/dev/null") return filePath;
|
|
return quoteGitPath(`${side}/${filePath}`);
|
|
}
|
|
|
|
export function parseDiffMetadataPathToken(token: string): string {
|
|
if (token === "/dev/null") return token;
|
|
return unquoteGitPath(token);
|
|
}
|
|
|
|
export function formatDiffMetadataPathToken(filePath: string): string {
|
|
if (filePath === "/dev/null") return filePath;
|
|
return quoteGitPath(filePath);
|
|
}
|
|
|
|
export function parseDiffFilePathLines(lines: string[]): DiffPathPair {
|
|
let oldPath: string | undefined;
|
|
let newPath: string | undefined;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("@@ ") || line === "GIT binary patch") break;
|
|
if (line.startsWith("--- ")) {
|
|
const parsed = parsePatchPathToken(line.slice(4), "a");
|
|
if (parsed && parsed !== "/dev/null") oldPath = parsed;
|
|
} else if (line.startsWith("+++ ")) {
|
|
const parsed = parsePatchPathToken(line.slice(4), "b");
|
|
if (parsed && parsed !== "/dev/null") newPath = parsed;
|
|
}
|
|
}
|
|
|
|
return { oldPath, newPath };
|
|
}
|
|
|
|
/**
|
|
* Extended-header line the review core injects into the display-only stub it
|
|
* emits for a file whose bytes exceed the review size cap. Without it the stub
|
|
* is indistinguishable from a genuine binary file, and the UI could only show
|
|
* a header-only card with no counts and no explanation — which reads as broken.
|
|
*
|
|
* Lives here (rather than in review-core) so the browser bundle can detect the
|
|
* shape without pulling the whole node-facing review core in. Both server
|
|
* runtimes get it from review-core, which is vendored to Pi alongside this file.
|
|
*
|
|
* The `#` prefix is what makes detection unambiguous: diff CONTENT lines are
|
|
* always prefixed with `+`, `-`, or a space, so a bare match on this exact line
|
|
* can only come from the extended header we wrote. Git ignores unknown
|
|
* extended-header lines, and @pierre/diffs parses the stub identically with or
|
|
* without it.
|
|
*/
|
|
export const OVERSIZED_REVIEW_STUB_MARKER = "#plannotator-oversized-file";
|
|
|
|
/**
|
|
* Human-readable form of the cap for UI copy. The authoritative byte value is
|
|
* `MAX_REVIEW_FILE_CONTENT_BYTES` in review-core, which is node-facing; a
|
|
* review-core test asserts the two never drift.
|
|
*/
|
|
export const OVERSIZED_REVIEW_STUB_LIMIT_LABEL = "5 MB";
|
|
|
|
/** True when `patch` is one of our oversized-file stubs (see the marker above). */
|
|
export function isOversizedReviewStubPatch(patch: string): boolean {
|
|
return patch.split("\n").some((line) => line === OVERSIZED_REVIEW_STUB_MARKER);
|
|
}
|
|
|
|
/**
|
|
* True when a single file's patch chunk carries a binary marker and no hunks,
|
|
* so a diff renderer has literally nothing to draw for it.
|
|
*
|
|
* The GENERAL case, of which `isOversizedReviewStubPatch` above is the one
|
|
* specific case we can name: git emits this shape for real binary files, and
|
|
* the review core emits it for files it declined to read. Either way the card
|
|
* renders as a bare header with no counts and no body, which reads as a broken
|
|
* or empty diff rather than as content that was deliberately not shown.
|
|
*
|
|
* Callers that can say something more specific should ask the marker predicate
|
|
* FIRST and fall back to this one, so a marker-carrying stub is explained once,
|
|
* by the message that knows why.
|
|
*
|
|
* Scanning stops at the first hunk header: content lines always carry a `+`,
|
|
* `-`, or space prefix, so a `Binary files ` line at column zero before any
|
|
* `@@ ` can only be the extended header git (or the stub builder) wrote.
|
|
*/
|
|
export function isContentlessBinaryPatch(patch: string): boolean {
|
|
let hasBinaryMarker = false;
|
|
for (const line of patch.split("\n")) {
|
|
if (line.startsWith("@@ ")) return false;
|
|
if (line.startsWith("Binary files ") || line === "GIT binary patch") {
|
|
hasBinaryMarker = true;
|
|
}
|
|
}
|
|
return hasBinaryMarker;
|
|
}
|
|
|
|
export function parseDiffMetadataPathLines(lines: string[]): DiffPathPair {
|
|
let oldPath: string | undefined;
|
|
let newPath: string | undefined;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("rename from ") || line.startsWith("copy from ")) {
|
|
const parsed = parseDiffMetadataPathToken(line.slice(line.indexOf(" from ") + " from ".length));
|
|
if (parsed !== "/dev/null") oldPath = parsed;
|
|
} else if (line.startsWith("rename to ") || line.startsWith("copy to ")) {
|
|
const parsed = parseDiffMetadataPathToken(line.slice(line.indexOf(" to ") + " to ".length));
|
|
if (parsed !== "/dev/null") newPath = parsed;
|
|
}
|
|
}
|
|
|
|
return { oldPath, newPath };
|
|
}
|