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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ dist/
scripts/parity/out/
.superpowers/
docs/superpowers/
apps/viewer/test-results/
apps/viewer/playwright-report/
test-results/
playwright-report/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ cd scripts/parity && python3 -m http.server 8099 # http://127.0.0.1:8099/index.h
- **"Loading…" 자가 치유 — 세 부분이 함께여야 동작한다.** Bun 1.3.12의 `$`(ShellPromise)는 `diff.ts`의 `BUILD_CONCURRENCY=8` git 서브프로세스 버스트가 외부 프로세스 생성 경합과 겹치면 resolve도 reject도 없이 영구 pending이 된다(자식은 사라지고 좀비도 없고 이벤트 루프도 정상인데 프라미스만 안 끝난다 — 실측). `ShellPromise`엔 `.timeout()`/`.kill()`이 없어 `Promise.race`가 유일한 레버다. 예전엔 `singleFlight`가 키를 `.finally()`에서만 지워 그 키가 **영구 오염**되고 이후 모든 요청이 죽은 프라미스에 합류했다. 지금은 ① `singleFlight`가 flight를 타임아웃과 race해 키를 풀고(`SingleFlightTimeoutError`로 호출자가 타임아웃을 구분한다), ② `awaitFlight`가 **타임아웃만** 503+`Retry-After`로 흡수하며(그 외 에러는 다시 던져 기존 동작 보존), ③ `browser/main.ts`의 `fetchDiff`가 503·네트워크 실패를 1회 재시도한다(403·400은 terminal). **따로 넣으면 어느 쪽도 동작하지 않는다** — 키를 안 풀고 재시도하면 같은 죽은 프라미스에 다시 합류한다. **상수 제약은 per-flight가 아니라 합이다**: `/api/diff`가 `resolveBaseCached` → `diffFlight`를 순차로 두 번 기다리므로 45+45=90 < `idleTimeout` 120(슬랙 30초). 재시도가 1회인 이유도 `BUILD_CONCURRENCY=8`이 호출당이라(전역 세마포어 아님) 시도가 겹치면 동시 git 서브프로세스가 배로 늘어 재시도가 스스로를 느리게 만들기 때문이다. **덮지 않는 곳**: `isGitRepo`는 flight 앞에서, `getRepoSummary`·`getFileBytes`는 뒤에서 돌고 flight로 안 감싸였다 — 거기서 매달리면 예전 실패 모드 그대로 `idleTimeout`이 소켓을 닫으며, `/api/blob`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다. 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.e2e.ts`.
- **`server.ts`의 `baseCache`는 반드시 모듈 스코프에 남아야 한다** (`diffCache`와 달리 `createHandler` 안으로 옮기지 마라). `diff-server.test.ts`의 "diffFlight 타임아웃" 테스트가 기본 타임아웃 서버로 캐시를 데운 뒤 **별도로 새로 띄운** `flightTimeoutMs:1` 서버가 그 warm 항목을 그대로 봐야만, `baseFlight`가 마이크로태스크로 1ms 레이스를 이기고 제어가 `diffFlight`까지 도달한다. 옮기면(구조적 격리라는 그럴듯한 이유로 그럴 수 있다) 두 번째 서버가 빈 캐시로 시작해 `baseFlight`가 miss로 되돌아가고, 그 테스트는 **조용히** 첫 번째 테스트와 똑같은 `baseFlight` 가드만 다시 증명한다 — 실패가 아니라 무증상 퇴화다. 두 테스트가 **정반대 조건**(하나는 캐시 미스, 하나는 히트)에 의존하므로 공용 픽스처로 합치지도 마라.
- **커버리지 100%가 무엇을 뜻하지 않는지 알 것.** `bunfig.toml`의 게이트는 line·function·statement만 세고 **branch를 세지 않는다.** 그래서 `if (x instanceof Response) return x;` 같은 한 줄 가드는 `if`가 실행되기만 하면 covered로 찍히고, 그 `return`이 한 번도 안 나가도 100%가 유지된다(실제로 503 반환 경로 4개가 그 상태였다). 새 분기를 넣을 때 커버리지 초록을 증거로 받지 말고, **일부러 그 분기로 들어가는 테스트**를 따로 둘 것.
- **대형 파일 자동 접힘의 기준은 "변경량"이지 "파일 길이"가 아니다 — `FileDiffMetadata.additionLines`는 이름이 거짓말을 한다.** 뷰어는 첫 등장한 대형 파일을 접어서 마운트하는데(`main.ts`의 `isLargeFile`), 그 판정에 쓰는 `changedLines`를 예전엔 `fileDiff.additionLines.length + fileDiff.deletionLines.length`로 셌다. 그런데 `FileDiffMetadata`의 그 두 필드(**`string[]`**)는 변경된 줄이 아니다 — 뷰어는 `parseDiffFromFile`로 파일 **전량**에서 diff를 만들어 `isPartial === false`이고, 그때 둘은 각각 새/옛 파일의 **전체 내용**이다(`types.ts`가 그렇게 문서화한다). 즉 실효 판정식이 "파일 길이 × 2 > 1500" ≈ "파일 길이 > 750"이 되어, 몇 줄만 고친 긴 문서가 접힌 채로 떴다(실측: **headerlab**의 1,166줄 `CLAUDE.md`에 +28/−2를 낸 변경이 2,306으로 읽혔다 — 이 레포의 CLAUDE.md가 아니다. 그 워킹트리에서 5개 파일이 이렇게 잘못 접혔다). 실제 변경 줄 수는 **`Hunk` 쪽 동명 `number` 필드**(`hunk.additionLines`/`hunk.deletionLines` — `+`/`-` 프리픽스 줄 수)에만 있고, 파일 헤더에 뜨는 `-M +N` 배지도 정확히 그걸 센다(`createFileHeaderElement` — 삭제 span을 먼저 push하고 `[data-metadata]`에 `order`가 없어 화면 순서가 `-M +N`이지 `+N -M`이 아니다) — 화면의 배지와 접힘 판정이 같은 값을 봐야 사용자에게 일관된다. `largeFile.ts`의 `countChangedLines(hunks)`가 그 합이다. **이 종류의 버그는 유닛이 원리적으로 못 잡는다**: `isLargeFile` 자체는 옳았고 호출부가 틀린 값을 넘겼을 뿐이라, 그 함수의 기존 테스트 다섯 개가 전부 통과한 채로 버그가 살아 있었다(같은 이유로 `main.ts`는 커버리지 게이트 밖이고 배선은 e2e가 맡는다). 회귀망: `large-file-collapse.e2e.ts` 3종이 세 방향을 **한 파일에서** 지킨다 — ① 2,000줄 파일에 3줄 변경 → 안 접힘, ② 800줄 전량 재작성(1,600줄 변경) → 접힘, ③ 1,000줄 lockfile(20줄마다 2줄만 바뀌어 변경량 196줄 → 임계값 한참 아래) → 이름 규칙으로만 접힘. 셋 다 뮤테이션으로 판별력을 확인했다(`countChangedLines`를 0으로 퇴화시키면 ②만, `LOCKFILE_NAMES`를 비우면 ③만 죽는다). **②③을 기존 스펙에 맡기면 안 된다** — 한때 `retokenize-cache.e2e.ts`(big.ts)와 `lockfile-freeze.e2e.ts`가 그 둘을 지킨다고 여기 적혀 있었지만 **둘 다 접힘 상태를 단언하지 않는다**: 전자는 헤더를 click해 펼친 뒤 하이라이트를 기다리고, 후자는 `hasHeader`만 보는데 헤더는 접혔든 펼쳐졌든 있다. 자동 접힘이 사라지면 그 click이 반대로 파일을 접어 뒤따르는 단언이 깨지는 **부수효과**로만 빨간불이 될 뿐이다. 게다가 그 lockfile 픽스처는 8k에서 1,596줄·30k에서 5,996줄을 변경하므로(실측) **크기 규칙만으로 이미 접힌다** — `LOCKFILE_NAMES`를 통째로 비워도 그 스펙은 초록이라 이름 규칙의 회귀망이 아니었다.
- **포크 패키지는 import 경로 + 재구성 타입만 수정** — 렌더/로직 변경 금지 (Foundation 원칙). 오버홀은 별도 plan에서. 예외는 건별 합의 + `[diffdeck]` 주석으로 upstream 이탈을 코드에 표기 + e2e 회귀망 동반일 때만. 현재 예외 4건 (1~3은 `packages/diffs`, 4는 `packages/trees`):
1. `DiffHunksRenderer.recycle()`의 하이라이터 동기 재획득 — 빠른 스크롤 headerless blink 완치 (`header-mount.e2e.ts` 극한 프로브가 회귀망).
2. 빈 렌더 윈도우(totalLines 0 = collapsed) 렌더를 plain-text + zero-range로 — 하이라이트 렌더가 범위를 무시하고 전체 파일을 동기 토크나이즈해 대형 lockfile 마운트가 수 초 프리징하던 것 완치. `renderDiff` sync/async 두 경로 + `RenderedDiffASTCache.emptyWindow` 표식(빈 풀을 확장 렌더가 재사용하면 processDiffResult가 throw — 표식이 확장 시 재렌더를 강제). 회귀망: `lockfile-freeze.e2e.ts` (30k줄 프리징 게이트 + 8k줄 sub-cutoff 펼침 무오류).
Expand Down
39 changes: 39 additions & 0 deletions apps/viewer/__tests__/viewer-large-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
countChangedLines,
isLargeFile,
LARGE_FILE_LINE_THRESHOLD,
} from "../browser/largeFile.ts";
Expand All @@ -25,3 +26,41 @@ describe("isLargeFile", () => {
expect(isLargeFile("src/yarn.lock.ts", 5)).toBe(false);
});
});

describe("countChangedLines", () => {
// FileDiffMetadata.additionLines/deletionLines(string[])는 이름과 달리
// "변경된 줄"이 아니다: parseDiffFromFile로 만든 diff는 isPartial === false라
// 그 둘이 각각 새/옛 파일의 **전량**이다. 실제 +/- 줄 수는 hunk 쪽 동명
// 숫자 필드에만 있다.
test("sums the +/- line counts across hunks", () => {
expect(
countChangedLines([
{ additionLines: 28, deletionLines: 2 },
{ additionLines: 5, deletionLines: 11 },
]),
).toBe(46);
});
test("a diff with no hunks counts as zero", () => {
expect(countChangedLines([])).toBe(0);
});
test("a long file with a small edit is not large", () => {
// headerlab의 CLAUDE.md 실측: 1166줄 파일에 +28/-2.
// 파일 전량(1166 + 1140 = 2306)을 세면 임계값을 넘어 접혔다.
expect(
isLargeFile(
"CLAUDE.md",
countChangedLines([{ additionLines: 28, deletionLines: 2 }]),
),
).toBe(false);
});
test("a huge rewrite of the same file is still large", () => {
expect(
isLargeFile(
"CLAUDE.md",
countChangedLines([
{ additionLines: LARGE_FILE_LINE_THRESHOLD, deletionLines: 1 },
]),
),
).toBe(true);
});
});
22 changes: 22 additions & 0 deletions apps/viewer/browser/largeFile.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import type { Hunk } from "@diffdeck/diffs";

// 자동 접힘의 문턱 — 한 파일에서 **변경된** 줄 수(`+`와 `-`의 합)와 비교한다.
// `countChangedLines` 도입 전에는 같은 값이 "파일 길이 × 2"와 비교돼 사실상
// 750줄짜리 파일을 걸렀다. 의미가 바뀌었지만 값은 의도적으로 유지했다 —
// 이제 대략 "750줄 파일을 통째로 다시 쓴 정도"가 접힘 기준이라 자동 접힘이
// 훨씬 드물어지는데, 그게 이 수정이 노린 바다. 값을 1,600 이상으로 올리면
// `large-file-collapse.e2e.ts` ②(800줄 전량 재작성 = 1,600줄 변경)가 먼저
// 빨간불이 된다 — 우연이 아니라 의도된 트립와이어다.
export const LARGE_FILE_LINE_THRESHOLD = 1500;

export const LOCKFILE_NAMES: ReadonlySet<string> = new Set([
Expand All @@ -24,3 +33,16 @@ const basename = (path: string): string => {
export const isLargeFile = (name: string, changedLines: number): boolean =>
LOCKFILE_NAMES.has(basename(name)) ||
changedLines > LARGE_FILE_LINE_THRESHOLD;

// 한 파일에서 실제로 변경된(`+`/`-` 프리픽스가 붙은) 줄 수.
//
// `FileDiffMetadata`에도 동명의 `additionLines`/`deletionLines`가 있지만 그건
// **string[]**이고, 뷰어처럼 파일 전량으로 파싱한 diff(`isPartial === false`)에서는
// 각각 새/옛 파일의 전체 내용이다 — 세면 "변경량"이 아니라 "파일 길이 × 2"가 된다.
// 실제 변경 줄 수는 hunk 쪽 동명 **숫자** 필드에만 있고, 파일 헤더에 뜨는
// `-M +N` 배지도 같은 값을 센다(`createFileHeaderElement` — 삭제 span을 먼저
// push하므로 화면 순서가 `-M +N`이지 `+N -M`이 아니다).
export const countChangedLines = (
hunks: readonly Pick<Hunk, "additionLines" | "deletionLines">[],
): number =>
hunks.reduce((sum, h) => sum + h.additionLines + h.deletionLines, 0);
5 changes: 2 additions & 3 deletions apps/viewer/browser/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { extractSnippet } from "./grab/snippet.ts";
import { resolveTextTarget, rowSide } from "./grab/textSelection.ts";
import { ensureImageCard, IMAGE_CARD_CSS } from "./imageCard.ts";
import { blobUrl, type ImageEntry, imageEntries } from "./imageDiff.ts";
import { isLargeFile } from "./largeFile.ts";
import { countChangedLines, isLargeFile } from "./largeFile.ts";
import { createParseCache } from "./parseCache.ts";
import {
FLATTEN_KEY,
Expand Down Expand Up @@ -887,8 +887,7 @@ const renderPatch = (unsorted: DiffFile[]): void => {
// collapsed on first sight.
if (!seenIds.has(f.name)) {
seenIds.add(f.name);
const changedLines =
fileDiff.additionLines.length + fileDiff.deletionLines.length;
const changedLines = countChangedLines(fileDiff.hunks);
if (isLargeFile(f.name, changedLines)) collapsedIds.add(f.name);
}
return {
Expand Down
26 changes: 26 additions & 0 deletions apps/viewer/e2e/fixtures/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ export interface FixtureRepoOptions {
* opt-in (bulkFiles/lockfileLines와 같은 이유).
*/
bigFileLines?: number;
/**
* Opt-in: commit `src/long.ts` with LONG_FILE_LINES lines and edit only a
* handful of them in the working tree — a file that is long but whose diff
* is small. large-file-collapse.e2e.ts 전용 (다른 스펙의 픽스처를 바꾸지
* 않도록 opt-in — bulkFiles/lockfileLines와 같은 이유).
*/
longFileSmallEdit?: boolean;
/**
* Opt-in: skip every working-tree edit so the diff is empty on launch
* (the untracked `data.txt` is still written — hidden behind the
Expand Down Expand Up @@ -107,6 +114,12 @@ const bulkFileLines = (marker: string, length = 200): string =>
`export const ${marker}_${i} = ${i}; // ${marker} filler line ${i}`,
).join("\n")}\n`;

// 길지만 변경은 작은 파일: 자동 접힘이 "파일 길이"가 아니라 "변경량"으로
// 판정되는지 가르는 픽스처. 구 로직(파일 전량 카운트)이면 4,000줄로 읽혀
// 접히고, 변경량(6줄)으로 읽으면 접히지 않는다.
const LONG_FILE_LINES = 2000;
const LONG_FILE_EDITED_LINES = 3;

// pnpm-lock.yaml 흉내: 실제 lockfile처럼 패키지 블록이 반복되는 YAML.
// mutate 시 20줄마다 버전만 바꿔 수천 줄짜리 현실적인 diff를 만든다.
const lockfileContents = (lines: number, mutate: boolean): string => {
Expand Down Expand Up @@ -164,6 +177,12 @@ export const makeFixtureRepo = (
bulkFileLines("base", bigFileLines),
);
}
if (options.longFileSmallEdit) {
writeFileSync(
join(dir, "src", "long.ts"),
bulkFileLines("base", LONG_FILE_LINES),
);
}
if (options.nestedChainFile) {
mkdirSync(join(dir, "src", "mid", "deep"), { recursive: true });
writeFileSync(
Expand Down Expand Up @@ -230,6 +249,13 @@ export const makeFixtureRepo = (
bulkFileLines("edited", bigFileLines),
);
}
if (options.longFileSmallEdit) {
const lines = bulkFileLines("base", LONG_FILE_LINES).split("\n");
for (let i = 0; i < LONG_FILE_EDITED_LINES; i++) {
lines[i] = lines[i]?.replaceAll("base", "edited") ?? "";
}
writeFileSync(join(dir, "src", "long.ts"), lines.join("\n"));
}
if (options.nestedChainFile) {
writeFileSync(
join(dir, "src", "mid", "deep", "nested.ts"),
Expand Down
96 changes: 96 additions & 0 deletions apps/viewer/e2e/large-file-collapse.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 자동 접힘의 판정 기준은 "파일 길이"가 아니라 "변경량"이다.
//
// 뷰어는 첫 등장한 대형 파일을 접어서 마운트한다(main.ts의 isLargeFile). 그
// "대형"의 기준이 되는 changedLines를 예전엔 `FileDiffMetadata`의
// additionLines/deletionLines 길이 합으로 셌는데, 그 둘은 이름과 달리 변경된
// 줄이 아니다: 뷰어는 파일 전량으로 diff를 만들어(`isPartial === false`) 두
// 배열이 각각 새/옛 파일의 **전체 내용**이 된다. 즉 실효 판정식이 "파일 길이
// × 2 > 1500"이라, 몇 줄만 고친 긴 문서가 접힌 채로 떴다(실측: headerlab의
// 1,166줄 CLAUDE.md에 +28/-2를 낸 변경이 2,306으로 읽혔다).
//
// 이 계약은 유닛이 원리적으로 못 잡는다 — isLargeFile 자체는 옳았고 호출부가
// 틀린 값을 넘겼을 뿐이라, 기존 isLargeFile 테스트 다섯 개가 전부 통과한 채로
// 버그가 살아 있었다. 그래서 배선까지 태우는 e2e가 회귀망이다.
//
// **세 방향을 여기서 함께 지킨다.** 예전엔 ①만 두고 ②를
// `retokenize-cache.e2e.ts`가, ③을 `lockfile-freeze.e2e.ts`가 지킨다고 적어
// 뒀는데 둘 다 사실이 아니었다. 그 두 스펙은 접힘 상태를 **단언하지 않는다**
// (전자는 헤더 click으로 펼친 뒤 하이라이트를 기다리고, 후자는 헤더 존재만
// 본다 — 헤더는 접혔든 펼쳐졌든 있다). 자동 접힘이 사라져도 클릭이 반대로
// 동작해 뒤따르는 단언이 깨지는 **부수효과**로만 빨간불이 될 뿐이다. 게다가
// lockfile 픽스처는 20줄마다 2줄을 바꿔 8k에서 1,596줄, 30k에서 5,996줄을
// 변경하므로(실측) **크기 규칙만으로 이미 접힌다** — LOCKFILE_NAMES를 통째로
// 비워도 그 스펙은 초록이라, 이름 규칙을 지키는 회귀망이 아니었다.
import { expect, hasCode, launchViewer, test } from "./fixtures/app.ts";

// ① 길지만 변경이 작은 파일: 2,000줄에서 3줄만 고친다. 줄을 교체할 뿐이라
// 옛 파일도 2,000줄이므로, 구 로직이면 새/옛 전량을 더해 정확히 4,000으로
// 읽혀 접혔다(실측). 변경량(6줄)으로 읽으면 접히지 않는다.
test("a long file whose diff is small must not start collapsed", async ({
page,
}) => {
const viewer = await launchViewer([], { longFileSmallEdit: true });
try {
await page.goto(viewer.url);
await expect(page.locator("#status")).toHaveText(/\d+ file\(s\)/, {
timeout: 15_000,
});
await expect
.poll(() => hasCode(page, "src/long.ts"), { timeout: 20_000 })
.toBe(true);
// 헤더 caret도 같은 말을 해야 한다: 펼쳐진 파일의 버튼은 "접기"를 제안한다.
await expect(page.locator('[data-fold="src/long.ts"]')).toHaveAttribute(
"aria-label",
"Collapse file",
);
} finally {
await viewer.stop();
}
});

// ② 반대 방향: 변경량이 실제로 임계값을 넘으면 여전히 접혀야 한다. 800줄
// 전량 재작성 = 1,600줄 변경으로 LARGE_FILE_LINE_THRESHOLD(1,500)를 넘긴다
// (실측). 이 단언이 없으면 changedLines가 항상 0에 가까운 값으로 퇴화해도
// ①은 그대로 초록이라(접히지 않는 게 기대값) 아무도 못 잡는다.
test("a diff larger than the threshold still starts collapsed", async ({
page,
}) => {
const viewer = await launchViewer([], { bigFileLines: 800 });
try {
await page.goto(viewer.url);
await expect(page.locator("#status")).toHaveText(/\d+ file\(s\)/, {
timeout: 15_000,
});
await expect(page.locator('[data-fold="src/big.ts"]')).toHaveAttribute(
"aria-label",
"Expand file",
{ timeout: 20_000 },
);
expect(await hasCode(page, "src/big.ts")).toBe(false);
} finally {
await viewer.stop();
}
});

// ③ 이름 규칙: lockfile은 변경량이 임계값 아래여도 접힌다. 1,000줄 픽스처는
// 20줄마다 2줄만 바꿔 변경량이 196줄에 그치므로(실측) 크기 규칙으로는 절대
// 접히지 않는다 — 접히는 유일한 근거가 largeFile.ts의 LOCKFILE_NAMES다.
test("a lockfile collapses by name even when its diff is small", async ({
page,
}) => {
const viewer = await launchViewer([], { lockfileLines: 1000 });
try {
await page.goto(viewer.url);
await expect(page.locator("#status")).toHaveText(/\d+ file\(s\)/, {
timeout: 15_000,
});
await expect(page.locator('[data-fold="pnpm-lock.yaml"]')).toHaveAttribute(
"aria-label",
"Expand file",
{ timeout: 20_000 },
);
expect(await hasCode(page, "pnpm-lock.yaml")).toBe(false);
} finally {
await viewer.stop();
}
});
Loading