diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 7e3de9f..8c59919 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -38,6 +38,32 @@ jobs: - run: bun install --frozen-lockfile - run: bun test + # 위아래 잡들은 setup-bun 기본값으로 돈다 — 루트 package.json에 `packageManager`· + # `engines.bun`이 없으므로 최신 Bun이다. 그런데 Bun 1.3.x의 `$`는 + # 64KB를 넘는 stdout을 받는 호출에서 영영 settle하지 않을 수 있고(업스트림은 + # 1.4.0에서 수정), 최신 Bun에서는 그 회귀가 원리적으로 안 보인다 — showBytes를 + # `$`로 되돌려도 `test` 잡은 초록이다. `bunx` 사용자는 자기 Bun을 쓰므로 1.3.x도 + # 여전히 대상이라, 그 결함의 회귀망만 1.3.x 마지막 판으로 고정해 따로 돌린다. + # 전량은 `test` 잡이 이미 돈다 — 여기엔 버전에 따라 판별력이 갈리는 것만 싣는다. + # 지원 하한이 1.4 이상이 되면(`engines.bun` 도입 등) 이 잡은 지운다. + test-bun13: + runs-on: ubuntu-latest + # 회귀하면 테스트가 스스로 10초 타임아웃으로 끝나지만, Bun 다운로드나 러너가 + # 매달리는 경우까지 GHA 기본값(6시간)을 태우지 않게 상한을 둔다. + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' + # `bun install`은 일부러 없다. 이 회귀망은 Bun·Node 내장과 로컬 파일만 쓰므로 + # 의존성이 필요 없고(설치 없이 1.3.14에서 통과·되돌리면 실패, 둘 다 실측), + # 설치를 넣으면 최신 Bun이 lockfile 포맷을 바꾸는 날 1.3.14가 그걸 못 읽어 + # 이 잡이 회귀와 무관하게 빨개진다 — 그때 쉬운 오답은 고정 판을 올리는 것이고, + # 그러면 이 잡의 존재 이유가 사라진다. + - name: Bun 1.3.x `$` never-settle regression + run: bun test apps/viewer/__tests__/diff-large-blob.test.ts + coverage: runs-on: ubuntu-latest steps: diff --git a/CLAUDE.md b/CLAUDE.md index e1350a2..d873cee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ diffdeck/ │ ├── extract-sources.test.ts # 합성 소스맵 fixture로 hermetic (외부 체크아웃 의존 없음) │ ├── css-inline-plugin.ts # *.css?inline import용 Bun 플러그인 (런타임/번들러 2분리) │ └── parity/ # 포크 렌더 패리티 하니스 (fixture·preload·smoke test·main·build·index.html) -├── .github/workflows/ # pr-check.yml (5잡 — 아직 required 아님, CI 항목 참고) + release.yml (release-please → npm) +├── .github/workflows/ # pr-check.yml (6잡 — 아직 required 아님, CI 항목 참고) + release.yml (release-please → npm) ├── .claude-plugin/ .codex-plugin/ # 플러그인 매니페스트 — release-please extra-files가 버전을 bump ├── CHANGELOG.md # 레포 루트에 있다 (release-please changelog-path의 앞 슬래시 — CI 항목 참고) ├── bunfig.toml # 테스트 preload + 커버리지 게이트/제외 (테스트 3레인 항목 참고) @@ -95,12 +95,12 @@ bun run format ### 기여 워크플로 (PR 필수) -- **`main`에 직접 push 금지 — 모든 변경은 브랜치 → PR로 진행한다.** (초기 extraction 시기의 main 직접 push 관례는 종료. 이미 배포·CI가 붙은 상태라 PR 리뷰를 거친다.) 브랜치를 파고 PR을 열면 `pr-check` CI(lint+format/typecheck/test/coverage/e2e — 다섯 잡, format은 lint 잡의 스텝)가 돌고, 사람이 리뷰·머지한다. +- **`main`에 직접 push 금지 — 모든 변경은 브랜치 → PR로 진행한다.** (초기 extraction 시기의 main 직접 push 관례는 종료. 이미 배포·CI가 붙은 상태라 PR 리뷰를 거친다.) 브랜치를 파고 PR을 열면 `pr-check` CI(lint+format/typecheck/test/coverage/e2e/test-bun13 — 여섯 잡, format은 lint 잡의 스텝)가 돌고, 사람이 리뷰·머지한다. - 커밋 메시지는 **Conventional Commits**(`feat:`/`fix:`/`docs:`/`chore:`/`refactor:`/`test:`/`ci:` …) — release-please가 이를 근거로 버전·CHANGELOG·릴리스를 관리하기 때문(`feat:`→minor, `fix:`→patch, `feat!:`/`BREAKING CHANGE:`→major; `docs`/`chore`/`test`/`ci` 등은 릴리스 미유발). ### CI / 릴리스 -- **`.github/workflows/pr-check.yml`** — PR마다 **다섯 잡**: lint(oxlint + format:check(oxfmt)가 같은 잡의 스텝)·typecheck·test·coverage(100% 게이트)·**e2e**. `bun install --frozen-lockfile`. e2e 잡은 러너에 Node를 고정(Playwright는 spec·fixture·globalSetup을 항상 Node로 실행)하고 `playwright install --with-deps chrome`으로 **실제 Google Chrome**을 깐다(`playwright.config.ts`가 `channel:"chrome"`이라 번들 Chromium으로는 안 된다). 실패 시 `apps/viewer/test-results/`를 아티팩트로 올린다 — 없으면 CI 전용 실패를 로컬에서 재현할 단서가 없다. **e2e를 게이트에 둔 이유**: happy-dom에는 레이아웃이 없어 "창 크기가 상태 전환에도 변하지 않는가"·"버튼이 실제로 페인트되는가"·"클릭해도 포커스가 남는가" 같은 계약은 유닛이 원리적으로 못 잡는다. 일부만 고르지 않고 전부 돌리는 이유는 비용이 싸서가 아니라(실측상 e2e 잡이 다른 잡보다 수십 배 무겁다) **어느 계약이 깨질지 미리 알 수 없어서**다 — 골라 돌리면 고르는 사람이 이미 답을 안다고 전제하게 된다. `timeout-minutes: 15` 안에서 감당된다. 개수·소요시간을 문서에 박아두지 않는다(반드시 드리프트한다); 개수는 `bunx playwright test --list`가, 소요시간은 `gh run list --workflow=pr-check.yml --status=success` → `gh run view --json jobs`가 답한다. +- **`.github/workflows/pr-check.yml`** — PR마다 **여섯 잡**: lint(oxlint + format:check(oxfmt)가 같은 잡의 스텝)·typecheck·test·coverage(100% 게이트)·**e2e**·**test-bun13**. **Bun 버전을 고정하는 건 `test-bun13` 하나뿐이다.** 나머지는 setup-bun 기본값인데, 그 기본값은 `bun-version-file`이나 루트 `package.json`의 `packageManager`·`engines.bun`이 있으면 거기를 따르고 **없어서 지금은 최신**이다 — `engines.bun`을 넣는 순간 다섯 잡의 Bun이 조용히 바뀐다. 고정하는 이유는 최신 Bun에서 `$` never-settle 회귀가 원리적으로 안 보이기 때문이고(아래 "Loading…" 항목), 그래서 그 잡엔 버전에 따라 판별력이 갈리는 회귀망(`diff-large-blob.test.ts`)만 싣는다. **그 잡에 `bun install`이 없는 건 의도다**: 그 회귀망은 내장·로컬 파일만 쓰고(설치 없이 통과·되돌리면 실패 — 실측), 설치를 넣으면 최신 Bun이 lockfile 포맷을 바꾸는 날 1.3.14가 못 읽어 회귀와 무관하게 빨개진다 — 그때의 쉬운 오답(고정 판 올리기)이 잡의 존재 이유를 지운다. 지원 하한이 1.4 이상이 되면(`engines.bun` 도입 등) 이 잡은 지운다. `bun install --frozen-lockfile`. e2e 잡은 러너에 Node를 고정(Playwright는 spec·fixture·globalSetup을 항상 Node로 실행)하고 `playwright install --with-deps chrome`으로 **실제 Google Chrome**을 깐다(`playwright.config.ts`가 `channel:"chrome"`이라 번들 Chromium으로는 안 된다). 실패 시 `apps/viewer/test-results/`를 아티팩트로 올린다 — 없으면 CI 전용 실패를 로컬에서 재현할 단서가 없다. **e2e를 게이트에 둔 이유**: happy-dom에는 레이아웃이 없어 "창 크기가 상태 전환에도 변하지 않는가"·"버튼이 실제로 페인트되는가"·"클릭해도 포커스가 남는가" 같은 계약은 유닛이 원리적으로 못 잡는다. 일부만 고르지 않고 전부 돌리는 이유는 비용이 싸서가 아니라(실측상 e2e 잡이 다른 잡보다 수십 배 무겁다) **어느 계약이 깨질지 미리 알 수 없어서**다 — 골라 돌리면 고르는 사람이 이미 답을 안다고 전제하게 된다. `timeout-minutes: 15` 안에서 감당된다. 개수·소요시간을 문서에 박아두지 않는다(반드시 드리프트한다); 개수는 `bunx playwright test --list`가, 소요시간은 `gh run list --workflow=pr-check.yml --status=success` → `gh run view --json jobs`가 답한다. - **lint 스코프**: 스크립트는 `oxlint apps/`/`oxfmt apps/`로 **owned 코드만** 대상(vendored `packages/*`는 Pierre 원본 스타일이라 lint/format 게이트 제외). `.oxlintrc.json`의 `typeAware:true` 때문에 `oxlint-tsgolint`(devDep)가 있어야 lint가 돈다. 테스트/e2e override(`**/__tests__/**`·`**/e2e/**`)에서 unbound-method·no-empty-pattern·no-unassigned-import 등 완화. - **워커 하이라이트 경로 활성화됨**: `build.ts`가 `packages/diffs/src/worker/worker.ts`를 `dist/viewer/worker.js`로 번들하고, `main.ts`가 `getOrCreateWorkerPoolSingleton`(poolSize 2)으로 만든 풀을 CodeView에 주입한다. 렌더 옵션 5필드(theme·useTokenTransformer·tokenizeMaxLineLength·lineDiffType·maxLineDiffLength)는 풀이 자기 옵션을 init 메시지로 워커에 밀어넣어 자기일관적이다 — 진짜 계약은 반대 방향: 이 5필드를 CodeView 레벨에서 오버라이드해도 워커 경로는 무시하므로, 바꾸려면 반드시 `getOrCreateWorkerPoolSingleton`의 `highlighterOptions`에 넣어야 한다. 회귀망: `worker-highlight.e2e.ts`(첫 진입 무스파이크 + plain→색 전이 + 폴백). - **`.github/workflows/release.yml` + `release-please-config.json` + `.release-please-manifest.json`** — release-please(모노레포: 배포 패키지 `apps/viewer`, `package-name @say8425/diffdeck`)가 conventional commits로 **릴리스 PR을 생성**하고, **사람이 그 PR을 머지**하면 release-please가 릴리스·태그를 커팅 → `releases_created == 'true'` 일 때 publish 잡이 `apps/viewer`에서 `bun run build` + `npm publish --provenance --access public`. @@ -109,7 +109,7 @@ bun run format - **게이트는 반드시 `== 'true'` 비교**: release-please-action은 아무것도 안 만들어도 `releases_created`/`prs_created`에 **문자열 `"false"`** 를 내보내는데, GHA는 비어있지 않은 문자열을 truthy로 취급한다. `if: ${{ ...release_created }}` 같은 bare truthy는 항상 통과해 publish가 오발한다(실제로 run 29477773636에서 발생 — main이 0.1.0인데 publish가 돌아 중복 버전으로 실패). - **publish 인증 = trusted publishing(OIDC, tokenless)**: publish 잡에 `NODE_AUTH_TOKEN` 없음 — npm CLI가 OIDC 환경(`id-token: write`)을 감지해 npmjs.com에 등록된 trusted publisher(org say8425/repo diffdeck/workflow release.yml)로 인증. 요건: **Node ≥ 22.14.0(워크플로는 24) + npm ≥ 11.5.1(`npm install -g npm@latest`)**. (0.1.0은 npm의 first-publish OIDC 부재(npm/cli#8544) 때문에 최초 1회 수동 토큰 publish로 부트스트랩됨.) - **사용자 게이트(부트스트랩, 자동화 불가)**: npm은 신규 패키지 최초 버전을 OIDC로 못 올린다(설정 UI가 패키지 존재를 요구, npm/cli #8544). 따라서 순서: ① `0.1.0`을 **로컬에서 토큰/`npm login`으로 1회 수동 publish**(패키지 생성) → ② npmjs.com 패키지 Settings에서 trusted publisher 등록(org `say8425`, repo `diffdeck`, workflow `release.yml`, environment 없음) → ③ 이후 release-please가 낸 릴리스 PR을 사람이 머지하면 CI가 tokenless로 publish. provenance엔 public repo 필요(✅). -- **CI는 아직 "신호"이고 "게이트"가 아니다 — required status checks가 비어 있다.** `main` 룰셋에는 `deletion`·`non_fast_forward`만 있고(`gh api repos/say8425/diffdeck/branches/main/protection` → 404 Branch not protected), 어떤 체크도 required로 지정돼 있지 않다. 그래서 **빨간 CI로도 머지 버튼이 열린다**(실측: e2e가 `IN_PROGRESS`인데 `mergeStateStatus: CLEAN`). e2e를 포함해 다섯 잡 전부에 해당하는 기존 상태다. 진짜 게이트로 만들려면 룰셋에 `required_status_checks`로 `lint`·`typecheck`·`test`·`coverage`·`e2e`를 등록해야 한다 — 워크플로 파일만으로는 안 된다. (같은 종류의 "코드가 아니라 저장소 설정" 항목이라 바로 아래 항목과 나란히 둔다.) +- **CI는 아직 "신호"이고 "게이트"가 아니다 — required status checks가 비어 있다.** `main` 룰셋에는 `deletion`·`non_fast_forward`만 있고(`gh api repos/say8425/diffdeck/branches/main/protection` → 404 Branch not protected), 어떤 체크도 required로 지정돼 있지 않다. 그래서 **빨간 CI로도 머지 버튼이 열린다**(실측: e2e가 `IN_PROGRESS`인데 `mergeStateStatus: CLEAN`). e2e를 포함해 여섯 잡 전부에 해당하는 기존 상태다. 진짜 게이트로 만들려면 룰셋에 `required_status_checks`로 `lint`·`typecheck`·`test`·`coverage`·`e2e`·`test-bun13`을 등록해야 한다 — 워크플로 파일만으로는 안 된다. (같은 종류의 "코드가 아니라 저장소 설정" 항목이라 바로 아래 항목과 나란히 둔다.) - **저장소 설정 요구사항**: Settings → Actions → General → Workflow permissions의 **"Allow GitHub Actions to create and approve pull requests"** 가 켜져 있어야 한다(기본 OFF). 꺼져 있으면 release-please가 버전 계산·브랜치·커밋까지 다 만들어놓고 **PR 생성 단계에서만** `GitHub Actions is not permitted to create or approve pull requests` 로 실패한다. API로는 `gh api -X PUT repos/say8425/diffdeck/actions/permissions/workflow -f default_workflow_permissions=read -F can_approve_pull_request_reviews=true`. - **배포 산출물**: `build.ts`가 `dist/cli.js`에 `#!/usr/bin/env bun` 셰뱅 + 실행권한을 부여해 `bunx`뿐 아니라 `npx`/직접 실행에서도 bun으로 구동된다(`// @bun` 마커는 셰뱅 다음 줄에 유지). **`apps/viewer/package.json`에는 `dependencies`가 하나도 없다 — 의도된 상태다**: tarball은 `files`에 적은 `dist`·`LICENSE`·`NOTICE`에 npm이 **`files`와 무관하게 항상 싣는** `README.md`·`package.json`을 더해 9개 파일이고(`npm pack --dry-run`으로 확인), 사용자는 `bunx` 시점에 아무것도 resolve하지 않는다. `apps/viewer/README.md`가 npm 패키지 페이지가 되는 것도 이 강제 포함 덕분이다 — `files`에 없다고 빼면 안 된다. 두 축이 다른 이유로 그렇다 — `dist/cli.js`(26KB)는 **애초에 외부 deps를 안 쓴다**(서버·CLI가 Bun `$`와 node 빌트인만 쓴다: 번들에 남은 import가 `fs`·`os`·`path`뿐), 반면 `dist/viewer/main.js`(10.9MB)는 shiki와 문법 전량을 **번들 안에 품는다**. `dist/viewer/worker.js`(836KB)는 다르다 — shiki **엔진만** 들었고 문법은 없다(`worker.ts`가 `createHighlighterCore({ themes: [], langs: [] })`로 빈 채 뜨고, 문법은 `WorkerPoolManager`가 postMessage로 밀어넣는다). 10.9MB 대 836KB의 격차가 정확히 그 차이다. 실측: `grep -o 'source\.ts' dist/viewer/main.js | wc -l` → 298, 같은 명령이 `worker.js`에선 0. **`grep -c`는 쓰지 마라** — 미니파이 번들은 사실상 한 줄이라 줄 수를 세면 298이 1로 보인다. 뒤집으면 **새 런타임 import를 추가할 때 `dependencies`에 적을 곳이 없다** — 번들에 실제로 들어갔는지 `bun run build` 후 산출물에서 확인해야 하고, external로 남은 것은 설치 시점에 그냥 없다. 산출물은 `dist/cli.js` + `dist/viewer/{main.js,worker.js,index.html}` + `dist/skills/diffdeck/SKILL.md` 다섯이다. @@ -132,7 +132,7 @@ cd scripts/parity && python3 -m http.server 8099 # http://127.0.0.1:8099/index.h - **CodeView 인스턴스 수명 — 재생성은 `!codeView`일 때만**: `renderPatch`가 CodeView를 새로 만드는 건 첫 렌더와 빈 상태 복귀뿐이다. unified↔split 전환은 살아 있는 인스턴스에 `setOptions(codeViewOptions())` → `setItems` → `render`로 태운다. 재생성하면 `diffMount.replaceChildren()`이 **스크롤 컨테이너 자신**(`#diff`가 곧 `diffMount`)을 비워 scrollHeight가 무너지고 브라우저가 scrollTop을 0으로 클램프해, 읽던 위치를 잃는다. 엔진은 `diffStyle`을 item-layout 옵션으로 취급하고(`hasItemLayoutOptionChanged`) `setOptions` 진입 즉시 `capturePendingLayoutAnchor()`로 앵커를 잡아 렌더 경로에서 `resolveAnchoredScrollTop()`으로 뷰포트를 붙든다(픽셀이 아니라 의미론적 앵커 — split은 scrollHeight가 대략 절반이라 픽셀 복원은 엉뚱한 파일에 착지한다). **호출 순서 자체가 계약이다**: `setOptions`가 `setItems`보다 먼저여야 앵커가 전환 전 레이아웃을 본다. `setOptions`가 건 인덱스 0 리셋이 뒤이은 `setItems`의 부분 리셋에 지워지지 않는 건 `markLayoutDirtyFromIndex()`가 기존 인덱스와 min을 취하기 때문이라, 순서를 뒤집으면 테스트가 못 잡는 채로 조용히 깨진다. `config.overscrollSize`를 생성 분기에서만 세팅해도 되는 이유도 여기 있다 — `setOptions`는 `config`를 건드리지 않아 이후 옵션 변경을 전부 살아남는다. 회귀망: `diffstyle-scroll.e2e.ts`(양방향 앵커 오프셋 + `data-diff-type`). - **평범한 데이터 갱신에도 픽셀 `scrollTo`를 얹지 말 것**: refresh·`--watch` 갱신 경로는 `setItems` → `render`만 부르고 스크롤 복원을 엔진에 맡긴다. `reconcileItems`가 `markLayoutDirtyFromIndex`를 세우면 렌더 경로가 `layoutDirtyIndex != null`을 보고 scroll correction을 무조건 재무장하고, 캐시된 앵커가 없으면 `getScrollAnchor`가 `renderState`에서 새로 만들어 보정한다 — 즉 이미 의미론적으로 보존된다. 여기에 `scrollTo({type:"position"})`를 얹으면 잉여가 아니라 **해롭다**: ① `position` 타깃은 애초에 항등 복원이 아니다 — `resolveScrollTargetTop`이 클램프되지 않은 값에서 `getStickyHeaderOffset()`(= `diffHeaderHeight` 44, 우리가 `stickyHeaders: true`를 주고 `disableFileHeader`를 안 주므로)을 빼므로, 모든 refresh·watch 폴링마다 뷰포트가 44px씩 위로 밀렸다, ② 뷰포트 *위쪽* 파일 길이가 변하면 아래 내용이 통째로 밀리는데 옛 픽셀로 되돌아가 읽던 줄이 어긋난다(실측: 60줄 증가에 1244px 드리프트), ③ `scrollTo`가 세우는 `pendingScrollTarget`을 프레임이 앵커보다 우선해 적용하므로, 스타일 전환의 렌더가 아직 큐에만 있는 1프레임 창에 갱신이 겹치면(그 창에선 `renderedDiffStyle`이 이미 갱신돼 이 갱신 분기로 들어온다) 전환 전 픽셀값이 앵커를 덮어써 split 기준 거의 바닥으로 착지한다. 보정이 항상 도는 건 아니고 그럴 필요도 없다: `setItems`엔 아무것도 dirty로 안 세우는 append 전용 경로가 있고(위쪽이 안 움직였으니 보정할 게 없다), 앵커 아이템이 사라지거나 목록이 렌더 윈도우보다 작아져 `renderState`가 리셋되면 앵커 해석이 비어 돌아온다 — 그 폴백은 클램프된 현재 위치라 옛 "값 −44"보다 낫다. 회귀망: `update-anchor.e2e.ts`(위쪽 파일 증가 + rAF 게이트로 결정화한 한 프레임 레이스). - **`#diff`에 innerHTML을 쓰기 전엔 살아 있는 CodeView가 없는지 확인**: 위와 같은 이유로 이 노드는 CodeView가 `setup()`에서 자기 컨테이너를 append한 스크롤 컨테이너라, 덮어쓰면 그 컨테이너가 문서에서 떨어져 나간다. `CodeView.setup()`은 이미 setup된 인스턴스의 재부착을 거부하므로(`already setup`) 인스턴스를 새로 만들기 전까지 패널이 영구히 빈 채로 남는다. 그래서 `load()`의 실패 카드는 `!codeView`로 가드한다 — `!lastFiles`가 아니다: 변경 없는 리포는 `lastFiles === []`(truthy)지만 `teardownViews()`로 이미 codeView가 비워진 뒤라 카드를 쓰는 게 안전하고, `!lastFiles`로 걸면 그 경우에 상태 라벨만 실패를 말하고 화면은 "No changes."를 계속 주장한다. 회귀망: `load-failure.e2e.ts`. -- **"Loading…" 자가 치유 — 세 부분이 함께여야 동작한다.** Bun 1.3.x의 `$`(ShellPromise)는 **64KB 파이프 버퍼를 넘는 stdout을 받는 호출에서** resolve도 reject도 없이 영구 pending이 될 수 있다 — 호출이 겹치면 거의 확정이고 완전 순차여도 결국 걸린다(자식은 사라지고 좀비도 없고 이벤트 루프도 정상인데 프라미스만 안 끝난다 — 실측; 1.3.12·1.3.14 재현, 업스트림은 1.4.0에서 수정). 예전엔 외부 프로세스 생성 경합이 필요하다고 봤지만 아니었다 — 200KB 파일 12개를 `BUILD_CONCURRENCY=8`로 읽으면 첫 호출에 죽고, 60KB는 멀쩡하다. 그 버스트의 주인인 `showBytes`는 그래서 `$`가 아니라 **`Bun.spawn`**이다. **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`는 행업을 1.3.x에서만 잡는다(CI의 `setup-bun`은 버전 미지정이라 최신을 쓰고, 거기선 `$`도 통과한다). 출력이 64KB를 넘을 수 있는 `$`는 아직 남아 있고, 그래서 아래 세 부품은 그대로 둔다 — 예: `diffFlight` 안의 큰 diff `git diff --name-status`·`ls-files --others`·지문의 `status -uall`(①②③ 전부), `refsFlight` 안의 `refs.ts` `for-each-ref`(원격 브랜치가 수천 개인 리포 — ①②만, ③ 재시도는 `/api/diff` 전용), 그리고 flight 밖의 `summary.ts` `diff --name-only`·`ls-files --others`(**셋 다 안 닿는다** — 아래 "덮지 않는 곳"). **새 git 호출의 출력이 64KB를 넘을 수 있으면 `$`가 아니라 `Bun.spawn`으로 쓴다.** `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`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다(단 `getFileBytes`의 blob 읽기는 `showBytes`라 이미지 크기로는 더 걸리지 않는다 — 거기 남은 `$`는 출력이 작은 `merge-base`뿐이다). 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.e2e.ts`. +- **"Loading…" 자가 치유 — 세 부분이 함께여야 동작한다.** Bun 1.3.x의 `$`(ShellPromise)는 **64KB 파이프 버퍼를 넘는 stdout을 받는 호출에서** resolve도 reject도 없이 영구 pending이 될 수 있다 — 호출이 겹치면 거의 확정이고 완전 순차여도 결국 걸린다(자식은 사라지고 좀비도 없고 이벤트 루프도 정상인데 프라미스만 안 끝난다 — 실측; 1.3.12·1.3.14 재현, 업스트림은 1.4.0에서 수정). 예전엔 외부 프로세스 생성 경합이 필요하다고 봤지만 아니었다 — 200KB 파일 12개를 `BUILD_CONCURRENCY=8`로 읽으면 첫 호출에 죽고, 60KB는 멀쩡하다. 그 버스트의 주인인 `showBytes`는 그래서 `$`가 아니라 **`Bun.spawn`**이다. **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`는 행업을 1.3.x에서만 잡으므로(최신 Bun에선 `$`도 통과한다) CI의 `test-bun13` 잡이 그 파일만 Bun 1.3.14로 고정해 돌린다. 출력이 64KB를 넘을 수 있는 `$`는 아직 남아 있고, 그래서 아래 세 부품은 그대로 둔다 — 예: `diffFlight` 안의 큰 diff `git diff --name-status`·`ls-files --others`·지문의 `status -uall`(①②③ 전부), `refsFlight` 안의 `refs.ts` `for-each-ref`(원격 브랜치가 수천 개인 리포 — ①②만, ③ 재시도는 `/api/diff` 전용), 그리고 flight 밖의 `summary.ts` `diff --name-only`·`ls-files --others`(**셋 다 안 닿는다** — 아래 "덮지 않는 곳"). **새 git 호출의 출력이 64KB를 넘을 수 있으면 `$`가 아니라 `Bun.spawn`으로 쓴다.** `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`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다(단 `getFileBytes`의 blob 읽기는 `showBytes`라 이미지 크기로는 더 걸리지 않는다 — 거기 남은 `$`는 출력이 작은 `merge-base`뿐이다). 회귀망: `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` 가드만 다시 증명한다 — 실패가 아니라 무증상 퇴화다. 두 테스트가 **정반대 조건**(하나는 캐시 미스, 하나는 히트)에 의존하므로 공용 픽스처로 합치지도 마라. - **빈 상태의 문구는 `/api/summary`가 와야 정해진다 — 먼저 그리면 말을 바꾼다.** `renderPatch`의 `files.length === 0` 분기는 한때 `
No changes.
`를 **동기로** 써 놓고, `enrichEmptyState()`가 요약을 받아 온 뒤 정보형 카드로 덮었다. 그 사이가 실측 60~80ms라 사용자에게는 "없다"고 한 번 말한 뒤 말을 바꾸는 것으로 보인다(실측: 첫 로드 673ms `No changes.` → 753ms `No tracked changes …`; head를 고르면 282ms → 340ms). **자동 base 전환이 걸리는 경우가 가장 나쁘다** — 볼 것이 있는데도 없다고 말한 뒤 diff가 뜬다. 그래서 지금은 자리만 잡고(`LOADING_MARKUP` — `load()`의 첫 로드 표시와 **같은 마크업이어야 한다**: 다른 것을 그리면 로딩에서 로딩으로 넘어가는 자리에서 한 번 더 깜박인다) 문구는 `enrichEmptyState`가 한 번만 쓴다. `No changes.`는 이제 **폴백**이고 요약 fetch가 실패했을 때만 나온다 — 그때 `data-loading`을 함께 걷어내지 않으면 화면이 영원히 로딩이다(노드 동일성은 유지해 marker 가드가 계속 맞게 둔다). **이 종류는 최종 상태로는 원리적으로 안 보인다**(끝나고 나면 옳은 카드가 떠 있다) — 회귀망은 `addInitScript`로 첫 페인트 전에 MutationObserver를 걸어 `#empty`가 거쳐 간 문구를 전부 기록한다(`empty-state.e2e.ts` ⑥⑦, 폴백은 ⑧이 `/api/summary`를 route abort로 막아 찌른다). 그 관찰자는 `document.documentElement`가 아니라 **`document`**를 봐야 한다 — document-start에는 `documentElement`가 아직 없어 기록이 통째로 빈다(실측). 뮤테이션으로 판별력 확인: 옛 동기 문구를 되돌리면 ⑥⑦만, 폴백 write를 지우면 ⑧만 죽는다. - **커버리지 100%가 무엇을 뜻하지 않는지 알 것.** `bunfig.toml`의 게이트는 line·function·statement만 세고 **branch를 세지 않는다.** 그래서 `if (x instanceof Response) return x;` 같은 한 줄 가드는 `if`가 실행되기만 하면 covered로 찍히고, 그 `return`이 한 번도 안 나가도 100%가 유지된다(실제로 503 반환 경로 4개가 그 상태였다). 새 분기를 넣을 때 커버리지 초록을 증거로 받지 말고, **일부러 그 분기로 들어가는 테스트**를 따로 둘 것. diff --git a/apps/viewer/__tests__/diff-large-blob.test.ts b/apps/viewer/__tests__/diff-large-blob.test.ts index 7b6f15d..05d939c 100644 --- a/apps/viewer/__tests__/diff-large-blob.test.ts +++ b/apps/viewer/__tests__/diff-large-blob.test.ts @@ -16,9 +16,9 @@ import { getDiffFiles } from "../server/diff.ts"; * 매번). 200KB 파일 12개면 첫 호출에서 죽고, 60KB(버퍼 미만)는 멀쩡하다. * * **판별력은 Bun 버전에 달렸다**: 업스트림이 1.4.0에서 고쳐, 1.4 이상에서는 - * `showBytes`를 `$`로 되돌려도 이 테스트가 통과한다(CI의 setup-bun은 버전 - * 미지정이라 최신을 쓴다). 1.3.x에서는 되돌리면 타임아웃으로 죽는다. 내용 - * 단언은 버전과 무관하게 큰 blob을 끝까지 읽는지를 지킨다. + * `showBytes`를 `$`로 되돌려도 이 테스트가 통과한다. 1.3.x에서는 되돌리면 + * 타임아웃으로 죽는다 — 그래서 CI의 `test-bun13` 잡이 이 파일만 Bun 1.3.14로 + * 고정해 돌린다. 내용 단언은 버전과 무관하게 큰 blob을 끝까지 읽는지를 지킨다. */ const FILES = 16;