diff --git a/AGENTS.md b/AGENTS.md index daca2fa5..4e6a24ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,7 @@ done | /usr/bin/grep -c . - Keep `rustjava-runtime/tests/classes` limited to Java standard library class and API behavior. - Test JVM and interpreter semantics, including class initialization, bytecode execution, and monitor behavior, with compiled Java fixtures under `test-data/src` and expected output under `test-data`, executed by `tests/test_class.rs`. - Do not place JVM core behavior tests in the `rustjava-runtime` standard library test tree. +- **Adding or recompiling a fixture means editing `test-data/class-file-versions.txt` in the same commit.** Every committed `.class` has its version frozen there, and a fixture that is not listed fails the pin — deliberately, since a pin that only covers what it already knows about stops covering anything added later. Run `python3 test-data/src/record-class-file-versions.py` and commit the row next to the fixture; a moved version means look at why before re-recording. ## Compatibility Sources - Implement Java compatibility from public specifications, Javadocs, and observable behavior tests. Do not consult or reproduce OpenJDK or other Java runtime implementation source code; keep the implementation independent to avoid licensing and provenance concerns. diff --git a/REPORT.md b/REPORT.md index e7dceb24..046782f3 100644 --- a/REPORT.md +++ b/REPORT.md @@ -18,6 +18,47 @@ **4종 전건 KILLED** 로 만들었다(`MakeConcatWrongDescriptor` · `MakeConcatWithArgument`). - ★**픽스처가 «출력»한다**(`ab`) — ★레시피를 틀린 길이로 합성해도 **링크되고 실행된다**. 값을 버리면 그 오류가 안 보인다(M2 가 그 증거). - 검증: `cargo test --all` **573 → 574 / 0 failed** · 픽스처 재생성 **멱등**(기존 4개 바이트 동일) · DoD 7명령 rc=0. +## [2026-09-17] 표 키의 경로 구분자를 «두 곳에서» 정규화한다 — 윈도우 CI red 를 고친다 (rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p0-fix) +- 무엇을: 표 키를 만드는 **두 곳**(Rust 테스트 · Python 기록기)을 **슬래시**로 맞췄다. ★**제품 코드 무접촉 · 설계 무변경.** +- 왜: 게이트② **request-changes**(PR #57 · 핀 `3061edb7`) — ★**윈도우 CI 가 red 였다**(`ci-presence` → `CI_RED` rc=1). +- 사용자 영향: 없다(시험 위생). ★바뀐 것은 **표 핀이 «모든 OS 에서» 같은 키를 쓴다**는 것이다. +- ★★**결함의 모양**: `to_string_lossy()` 가 윈도우에서 `indy\StringConcat.class` 를 만드는데 표는 `indy/…` 를 담는다 ⇒ + ★**하위 36개가 «미기록»과 «유령 기록» 양쪽에 «동시에» 걸린다.** ★**루트 114개는 통과**하므로 ★**mac/linux 로는 절대 안 보인다** + (모수 실측: **150 = 114 + 36**). +- ★★**「두 곳」이 급소다** — ★**같은 결함이 기록기에도 있었고**, Rust 만 고치면 ★**윈도우에서 기록한 표가 이번엔 mac/linux 를 red** 로 만든다. + ★**그 축은 CI 가 기록기를 안 돌려서 «영영 조용하다»** ⇒ 둘을 함께 고쳤다(`as_posix()`). +- ★**형제 상호작용(②)은 «코드»가 아니라 «순서»였고, 그 사이 `#55` 가 먼저 착지해 해소된 형태로 나타났다**: + base 를 당기니 그 3개가 **정확히 red 로 잡혔고**, 고친 기록기로 재기록하니 **150 → 153**(diff 정확히 3줄). + ⇒ ★**기계가 설계대로 잡고 처방이 한 명령이었다는 실증.** +- ★**정정 하나 더(③)**: `STATE.md` 에 착지해 있던 「#55 는 원장 3파일만 만진다」가 **거짓**이었고, + ★**그 오기 때문에 ②가 보이지 않았다** ⇒ 그 자리에서 반증을 붙였다. +- ★**이 설계의 «본래 대가»를 이제 «적어 뒀다»**: 「기록에 없는 픽스처 = red」는 의도된 엄격함이고, + 그 비용(픽스처를 더하는 회차가 표를 함께 만진다)이 **어디에도 없었다** ⇒ **`AGENTS.md` §Testing Boundaries 에 한 줄**. +- 검증: ⒞ 정규화를 되돌리면 ★**mac 에서도** 단위 시험이 진다(그래서 `components()` 대신 **문자열 치환**으로 구현했다 — + 전자는 Unix 에서 역슬래시 입력을 그냥 통과시켜 ★개악이 안 잡힌다) · Python 축은 `PureWindowsPath` 로 **윈도우 없이** 실증 · + `cargo test --all` **0 failed** · DoD 7명령 rc=0. + +## [2026-09-17] `test-data` 전체의 클래스 파일 버전을 «동결»했다 — 통일이 아니라 ★**[위 `-fix` 가 윈도우 경로 축을 정정]** (rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p0) +- 무엇을: 커밋된 픽스처 **150개**의 `.` 를 표에 기록하고, ★**혼자 움직이면 red** 가 되게 했다. +- 왜: 채택 제안 `2026-09-16-indy-fixture-jdk-pin-and-slot-accounting#p0`. +- 사용자 영향: 없다(시험 위생). ★바뀐 것은 **「다른 JDK 로 재생성했는데 아무도 모르는」 경로가 닫힌 것**이다. +- ★★**제안의 «비용 산정»을 바꿨다 — 기각이 아니라 «설계 교체»**: 제안은 + 「**decide the intended target** per fixture … recompiled … **The decision is the work**」라며 **통일**을 전제했는데, + 정작 제안이 산 이득은 「a regenerated fixture **cannot quietly start testing a different Java version's** shapes」 = + ★**«드리프트 탐지»**다. ⇒ 목표를 **동결**로 바꾸면 ★**그 「work」가 통째로 사라진다** — 재컴파일 **0** · 바이트 변경 **0** · + 「어느 타깃이 옳은가」 **결정 불요**. +- ★**대가를 숨기지 않는다**: 이 축은 ★**섞임을 «고치지» 않고 «굳힌다»**. 통일하려면 재컴파일해야 하고, + ★**재컴파일은 바이트를 바꿔 그 픽스처가 «무엇을 시험하는지»를 바꿀 수 있다**(제안 자신의 경고) ⇒ 별 축으로 넘겼다(후속 L). +- ★**ⓑ 이미 핀하는 축이 둘 있었고, 그 둘이 못 덮는 곳이 대상이었다**: `test_fixture_pins.rs` 는 **indy 만** · + 생성기는 **자기 산출물 16개**의 버전을 소스에 박는다 ⇒ ★**남는 132개가 「javac 로 손수 재컴파일 가능」한 것**이고, + 루트의 **66×8 · 68×1 · 70×3** 이 그 사고의 지문이다. +- ★**세 방향을 전부 검사한다**: ⑴기록과 다름 ⑵★**기록에 없는 새 픽스처**(없으면 핀이 «옵트인»이 되어 내일 추가분이 조용히 미보호) ⑶유령 기록. + 개악 **3종 전건 red**(M1 버전 바이트 `65→70` · M2 미기록 픽스처 · M3 유령 행) · 복원 **green**. + ★실패 문면이 **파일·두 버전·해소 명령**을 함께 말한다(`Hello.class: recorded 65.0, found 70.0`). +- 검증: 재측 인구조사(총 150 · 루트 5버전) · 기록기 **멱등**(재실행 시 표 바이트 동일) · + `cargo test --all` **572 → 573 / 0 failed / 1 ignored** · DoD 7명령 rc=0. +- 후속 추천: 루트 픽스처를 **하나의 타깃으로 재컴파일할지 «판정»**(L) — ★동결은 「움직였나」를 답하지 「우리가 원하는 값인가」를 답하지 않는다. + 상세 = `docs/worklog/2026-09-17-test-data-version-freeze.md`. ## [2026-09-17] 「어느 javac 이 만들었나」를 «기록»이 아니라 «검증»으로 바꿨다 (rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p1) - 무엇을: 기록된 도구로 **다시 빌드해 바이트를 비교**하는 검사를 만들었다. ★**제품 코드 무접촉**(Rust 변경은 doc 주석 1곳). - 왜: 채택 제안 `2026-09-16-indy-fixture-jdk-pin-and-slot-accounting#p1`. diff --git a/STATE.md b/STATE.md index 1d37cacf..aa427e04 100644 --- a/STATE.md +++ b/STATE.md @@ -41,6 +41,66 @@ 열린 PR **#57**(ci-pending)이 「`.class` 미등재는 핀이 **실패**시킨다」(`test-data/class-file-versions.txt`)를 세우므로, ★**이 PR 이 먼저 착지하면 #57 의 표에 이 셋이 «없어»** 그 회차가 red 가 된다(해소 = `record-class-file-versions.py` 재생성). ★**텍스트 충돌 0 이라 `mergeable` 로는 보이지 않는다** — 같은 고지가 #60 회신에도 있다(그쪽은 다른 3개). +- [rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p0-fix] ★★**표 키의 경로 구분자를 «두 곳»에서 정규화 — 윈도우 CI red 를 고쳤다.** + 게이트② **request-changes** 승계(PR #57 · 핀 `3061edb7` · ★`ci-presence` → **`CI_RED` rc=1**). ★**제품 코드 무접촉 · 설계 무변경** + (검수자가 「설계는 옳고 세 방향 전부 실제로 문다」로 확인했다 — ★**그 축은 다시 열지 않았다**). + ★★**결함의 모양**: `to_string_lossy()` 가 윈도우에서 `indy\StringConcat.class` 를 만드는데 표는 `indy/…` 를 담는다 ⇒ + ★**하위 36개가 «미기록»과 «유령 기록»에 «동시에» 걸린다.** ★**루트 114개는 통과**하므로 ★**mac/linux 로는 «절대» 안 보인다** + — 모수 실측 **150 = 루트 114 + 하위 36**(검수자 수와 일치). + ★★**「두 곳」이 급소다**: ★**같은 결함이 기록기(`record-class-file-versions.py`)에도 있었다** — Rust 만 고치면 + ★**윈도우에서 기록한 표가 이번엔 mac/linux 를 red** 로 만들고, ★**CI 가 기록기를 안 돌려 그 축은 «영영 조용하다».** + ⇒ 둘을 함께 고쳤다(Rust 구분자 정규화 · Python `as_posix()`). ★표 자체는 **이미 슬래시**였다(역슬래시 행 **0** 실측). + ★★**②는 «코드»가 아니라 «순서»였고 — 그 사이 `#55` 가 먼저 착지해 «해소된 형태»로 나타났다**: + base 를 당기니 픽스처 3개가 ★**정확히 red 로 잡혔고**(`NotFactoryDescriptor`·`NotInvokeStaticFactory`·`NotMakeConcatWithConstants`), + ★**고친 기록기로 재기록하니 150 → 153 · diff 가 정확히 3줄**(그 외 무변). + ⇒ ★**기계가 설계대로 «잡고», 처방이 «한 명령»이었다는 실증이다.** + ★**남은 형제 실측**: `#56` 은 test-data 접촉 **0** · `#58` 은 `test-data/src/verify-javac-fixtures.sh` **스크립트뿐** ⇒ + ★**둘 다 표와 상호작용하지 않는다** — 착지 순서 제약 **없음**. + ★**③ 오기 정정**: `STATE.md` 에 착지해 있던 「**#55 는 원장 3파일만 만진다**」가 **거짓**이었고 + ★**그 한 줄 때문에 ②가 보이지 않았다** ⇒ 그 자리에 반증을 붙였다. ★**교훈: 「어느 파일을 만지나」는 «PR 파일 목록»으로 확인하라.** + ★★**이 설계의 «본래 대가»를 이제 적어 뒀다** — 「기록에 없는 픽스처 = red」는 **의도된 엄격함**이고 + 그 비용(픽스처를 더하는 회차가 표를 함께 만진다)이 **어디에도 없었다**(실측: `AGENTS.md` 관련 문장 0) + ⇒ ★**`AGENTS.md` §Testing Boundaries 에 한 줄**로 남겼다. + ★**개악 대조(⒞)**: 정규화 한 줄을 되돌리면 ★**mac 에서도** `table_keys_use_forward_slashes_on_every_platform` 가 **진다** · 복원 green. + ★★**그래서 «문자열 치환»으로 구현했다** — `components()` join 이면 Unix 에서 역슬래시 입력이 **그대로 통과**해 + ★**개악이 mac 에서 안 잡힌다**(대가: Unix 파일명의 진짜 역슬래시는 바뀐다 — 픽스처엔 없고 표는 우리 것이다). + ★Python 축은 **윈도우 없이** 실증했다(`PureWindowsPath`: `str()` 역슬래시 ↔ `as_posix()` 슬래시). + ★★**게이트③ 착지 — PR #57 · `--merge`**(등재 repo · `merge_strategy: merge` 선언분). 게이트② **approve**(★`-fix2` 승계분) · + ★**핀 `888d8821` 불이동**(착수 실측 2026-09-17T06:21:43Z · `MERGEABLE/CLEAN` · base 뒤처짐 «0»). + ★★**게이트③이 «두 번» 돌았다 — 첫 회차는 «옳게» 막혔다**: base 를 당기면 이 PR 자신의 핀 테스트가 red 였다 + (형제 #59 가 들여온 `MakeConcat*` 3개가 표에 없었다) ⇒ 머지 티켓은 코드를 고치지 않으므로 `-fix2` 로 넘겼고, + 그 회차가 base 당김 + 표 **3행**(`record-class-file-versions.py` 1회 · 추가만 · 삭제 0)을 넣어 풀었다. + ⇒ ★**이 PR 이 세운 규율이 이 PR 에 처음 적용된 사례**이고, 검사기를 완화하지 않고 표를 고쳐 통과했다. + ★핀에서 `ci-presence` **rc=0 CI_GREEN** · 이 형상에서 `cargo test --test test_fixture_pins` **3/3 green**(이 PR 이 만든 그 축). + ★**동봉은 이 기록 한 줄뿐**이다 — 원장(worklog 쌍·`STATE`·`REPORT`)은 구현 회차가 이미 실었다. + ★배포 **0**(이 저장소에 배포 워크플로 없음) · 자식 PR **0건** · 주기 자동 커밋 **0건** · 낡음 판별 도구 **0건**. + ★★**착지 순서 고지** — 이 PR 이 세운 규율(「`.class` 를 더하면 `test-data/class-file-versions.txt` 에 같은 커밋으로 행을 넣는다 · + 미등재 픽스처는 핀이 **실패**시킨다」)은 ★**열린 PR #60(신규 `.class` 3개)·#61(17개)에 «소급 적용»된다** — + 그쪽이 표에 행을 넣지 않고 착지하면 **main 이 red** 가 된다. 텍스트 충돌이 없어 `mergeable` 로는 보이지 않는 종류다. +- [rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p0] ★★**`test-data` 전체(150 클래스)의 클래스 파일 버전을 «동결»했다 — «통일»이 아니라.** + 채택 제안 `2026-09-16-indy-fixture-jdk-pin-and-slot-accounting#p0`(worklog json `adoptedProposals` 기록). + ★★**제안의 «비용 산정»을 바꿨다 — 기각이 아니라 «설계 교체»다.** 제안은 「**decide the intended target** per fixture or + per directory … recompiled … ★**The decision is the work**」라며 **통일**을 전제했는데, 정작 제안이 적은 이득은 + 「a regenerated fixture **cannot quietly start testing a different Java version's** bytecode shapes」 = ★**«드리프트 탐지»**다. + ⇒ ★**목표를 «동결»로 바꾸면 그 「work」가 통째로 사라진다**: 지금 값을 기록하고 혼자 움직이면 red — + ★**재컴파일 0 · 바이트 변경 0 · 「어느 타깃이 옳은가」 결정 자체가 불요.** + ★**대가는 정직하게**: 이 축은 ★**섞임을 «고치지» 않고 «굳힌다»**(루트에 52·65·66·68·70 이 그대로 남는다). + 그것이 옳은 이유는 ★**통일 = 재컴파일 = 바이트 변경 = 「그 픽스처가 무엇을 시험하는지」의 변경**이기 때문이고(제안 자신의 경고), + 그 판정은 **별 축(후속 L)** 으로 넘겼다. + ★★**ⓑ 이미 핀하는 축이 «둘» 있었고, 그 둘이 못 덮는 곳이 이 회차의 대상이었다**: + ⑴`tests/test_fixture_pins.rs` 는 **`test-data/indy` 만** ⑵**생성기 코드**가 자기 산출물 **16개**의 버전을 소스에 박는다 + (`make_ldc_fixtures.py` 의 `major=52` + 항목별 오버라이드) ⇒ ★**남는 132개 = 「`.java` 소스가 있어 손수 재컴파일 가능한 것」**이고, + ★**루트의 66×8 · 68×1 · 70×3 이 그 사고가 «이미 일어난» 지문**이다. + ★**만든 것**: `test-data/class-file-versions.txt`(150행 + 머리 주석에 「이것은 타깃이 아니라 동결이다」) · + `test-data/src/record-class-file-versions.py`(머리 주석 **보존** · 없으면 **거부** · docstring 이 「실패를 잠재우려고 돌리지 마라」를 못박는다) · + 테스트 `committed_fixtures_keep_their_recorded_class_file_version`. + ★★**세 방향을 «전부» 검사한다 — 이것이 급소다**: ⑴기록과 다름 ⑵★**기록에 없는 새 픽스처** + (★없으면 핀이 «옵트인»이 되어 **내일 추가되는 픽스처는 조용히 미보호** — 이 저장소가 반복해 잡은 그 형태의 변종) ⑶**유령 기록**. + ★**개악 3종 전건 red**: M1 `Hello.class` 버전 바이트 `65→70`(= 다른 JDK 재생성과 **같은 형상**) · + M2 미기록 `.class` 투입 · M3 표에만 있는 행 ⇒ 복원 **green**(`test_fixture_pins` **2 passed**). + ★실패 문면이 **파일·두 버전·해소 명령**을 함께 말한다(`Hello.class: recorded 65.0, found 70.0`). + ★**기록기 멱등**(재실행 시 표 **바이트 동일**) · `cargo test --all` **572 → 573 / 0 failed / 1 ignored** · DoD **7줄 전건 rc=0**. + ★**알고 남긴 값**: 생성기 산출물 16개는 **이중 잠금**(생성기 + 표)이다 — ★**예외 목록을 두는 규칙보다 «전건 단일 규칙»이 덜 썩는다.** - [rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p1] ★★**「어느 javac 이 만들었나」를 «기록»에서 «검증»으로 바꿨다.** 채택 제안 `2026-09-16-indy-fixture-jdk-pin-and-slot-accounting#p1`(worklog json `adoptedProposals` 기록). ★**제품 코드 무접촉.** ★★**제안의 결론 «둘»이 실측으로 반증됐다 — 그래서 제안이 «불가능»하다고 적은 쪽을 만들었다**: @@ -232,6 +292,11 @@ ★**여파**: 이 착지가 형제 **#54**(BSM 정적 인자)·**#55**(변이 감사)의 base 를 낡게 만든다. ★**#54 는 «코드 파일이 자동 병합»되도록 그 회차가 삽입 위치를 미리 갈라 뒀고**(그 done 회신의 `merge-tree` 실측), **#55 는 원장 3파일만 만진다** ⇒ 두 형제 모두 충돌은 **원장 계열에 국한**된다(게이트③ 계약 2-c⒜ 범위). + ★★**[정정 2026-09-17 · `…-jdk-pin-…-p0-fix`] 바로 윗줄의 「#55 는 원장 3파일만 만진다」는 «거짓»이었다.** + 실측: `#55` 는 `test-data/indy/` 에 **`.class` 3개를 추가**하고 생성기·테스트도 만진다 ⇒ ★**원장만이 아니다.** + ★**그 오기가 «무해하지 않았다»** — 같은 시기 `#57`(버전 동결 표)이 「픽스처가 늘면 표도 함께」를 요구하는데, + ★**「#55 는 원장만」이라고 읽으면 그 상호작용이 «보이지 않는다».** 실제로 그 둘은 **텍스트 충돌 0인데 나중에 착지하는 쪽이 main 을 red** 로 만들었다. + ⇒ ★**교훈: 「어느 파일을 만지나」는 «PR 파일 목록»으로 확인하라 — 요약에서 추론하지 마라.** - [rustjava-ldc-tags-15-16-17-real-world-generator-survey] ★★**「못 쟀다」를 «쟀다»로 바꿨다 — ASM 은 태그 15/16/17 을 «낸다».** 채택 제안 `2026-09-16-ldc-tags-15-16-17#p2`(worklog json `adoptedProposals` 기록). ★**조사 회차 · 크레이트 무접촉**(파서·테스트 0). ★★**ASM 9.7.1 = 낸다(실증)** — `visitLdcInsn(Handle)`·`(Type.getMethodType)`·`(ConstantDynamic)` 15줄로 만든 클래스에서 diff --git a/docs/worklog/2026-09-17-test-data-version-freeze.json b/docs/worklog/2026-09-17-test-data-version-freeze.json new file mode 100644 index 00000000..1d68656c --- /dev/null +++ b/docs/worklog/2026-09-17-test-data-version-freeze.json @@ -0,0 +1,54 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-17", + "taskId": "rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p0", + "summary": "Froze the class file version of all 150 committed fixtures in a checked-in table, rather than unifying them on a target. The proposal's stated benefit is drift detection, and a freeze buys that with zero recompiles — so the 'decide a target per directory' work it called the main cost is not needed. Corrected after gate-2 review: the two places that build a table key used the platform separator, so all 36 subdirectory fixtures failed on Windows while the 114 in the root passed.", + "changes": [ + "test-data/class-file-versions.txt: new, 150 rows of . , with a header saying it is a freeze rather than a target and why", + "test-data/src/record-class-file-versions.py: new, rewrites the table from disk; preserves the header and refuses to write without one", + "tests/test_fixture_pins.rs: committed_fixtures_keep_their_recorded_class_file_version, checking all three directions", + "tests/test_fixture_pins.rs: table_key() normalises separators and is unit-tested, so the rule is checkable without Windows", + "test-data/src/record-class-file-versions.py: as_posix(), so recording on Windows cannot write backslash rows that would then break everyone else", + "test-data/class-file-versions.txt: re-recorded after the fix — 150 -> 153 rows, the three fixtures PR #55 added", + "AGENTS.md: one line stating that adding or recompiling a fixture means editing the table in the same commit", + "STATE.md: corrected a landed claim that PR #55 touched only ledger files; it adds three .class fixtures, and that error is why the interaction was invisible" + ], + "verification": [ + "census re-measured at start: root 52 x40, 65 x62, 66 x8, 68 x1, 70 x3; ldc 52 x7, 55 x6; indy 52 x1, 65 x6; 150 classes total — the proposal's numbers still hold in shape, 65 moved 61 -> 62 because this lineage added fixtures", + "existing coverage measured: tests/test_fixture_pins.rs pins only test-data/indy, and the generators pin their 16 outputs in source (make_ldc_fixtures.py has major=52 by default with per-fixture overrides)", + "the unpinned population is therefore the 132 javac-compiled fixtures that have .java sources — the ones a person can recompile by hand, which is exactly where the 66/68/70 outliers are", + "mutation M1, flipping Hello.class's version bytes 65 -> 70 (the same shape as a regeneration on another JDK): red, with the message naming the file, both versions and the fix", + "mutation M2, adding an unrecorded .class: red — without this direction the pin would be opt-in and new fixtures would be silently uncovered", + "mutation M3, adding a table row for a file that does not exist: red", + "restore: test_fixture_pins 2 passed", + "the recorder is idempotent: rerunning it leaves the table byte-identical", + "cargo test --all: 573 passed / 0 failed / 1 ignored (572 before; +1 test)", + "DoD 7 commands all rc=0", + "the reviewer's numbers reproduced: 150 fixtures = 114 root + 36 in subdirectories, and ci-presence on the reviewed pin reports CI_RED rc=1", + "existing table had zero backslash rows, so normalising alone is sufficient — no re-record was needed for that reason", + "Rust side unit-tested: table_key('dispatch\\\\base\\\\PackageBase.class') == 'dispatch/base/PackageBase.class'", + "Python side tested without Windows via PureWindowsPath: str() yields backslashes, as_posix() yields slashes", + "mutation: reverting the normalisation makes the unit test fail on macOS — which is why it is implemented as a string replacement rather than a components() walk, since the latter would let a backslash path through unchanged on Unix and the mutation would be invisible", + "finding 2 resolved by landing order: PR #55 landed first, pulling base made the pin fail naming exactly its three fixtures, and re-recording with the fixed recorder added exactly three rows and changed nothing else", + "remaining open siblings measured: #56 touches no test-data, #58 touches only a script, so neither interacts with the table" + ], + "issues": [ + "This freezes the heterogeneity rather than fixing it: five majors remain in the root.", + "The 16 generator outputs are double-locked (generator source plus the table), deliberately — one uniform rule rots less than a rule with an exemption list.", + "The strictness is the point and it has a standing cost: every round that adds a fixture must touch the table. That is now stated in AGENTS.md rather than only in this worklog." + ], + "adoptedProposals": [ + "2026-09-16-indy-fixture-jdk-pin-and-slot-accounting#p0" + ], + "proposals": [ + { + "title": "Decide whether the root fixtures should be recompiled onto one target", + "plainSummary": "The main test-data directory holds fixtures built by five different Java versions. We froze that, which stops it drifting further, but did not decide whether it should be uniform.", + "userBenefit": "None directly. It would make a fixture's bytecode shapes predictable from one number instead of from its individual history.", + "why": "The freeze added in this round answers 'did it move', not 'is it what we want'. A fixture compiled at major 52 exercises pre-invokedynamic shapes; one at 70 may lower the same source differently. Nobody has decided which of those each test wants, and the current spread is an accident of when each file was last touched rather than a choice.", + "tradeoff": "Recompiling changes bytes, and a fixture's bytes are what its test asserts against — several compare output against a committed .txt. So this is not a bulk operation: it needs a per-fixture judgement about what that test is for, and the likely honest answer for most of them is 'leave it', which costs the review time and buys nothing.", + "effort": "L", + "target": "test-data/, test-data/src/" + } + ] +} diff --git a/docs/worklog/2026-09-17-test-data-version-freeze.md b/docs/worklog/2026-09-17-test-data-version-freeze.md new file mode 100644 index 00000000..466afe40 --- /dev/null +++ b/docs/worklog/2026-09-17-test-data-version-freeze.md @@ -0,0 +1,135 @@ +# 2026-09-17 — `test-data` 전체의 클래스 파일 버전을 «동결»한다 + +티켓 `rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p0` — 채택 제안 +`2026-09-16-indy-fixture-jdk-pin-and-slot-accounting#p0`. + +★**제안의 목적은 샀고, 제안이 「the work」라 부른 비용은 «치르지 않았다».** + +★★**[정정 2026-09-17 · 게이트② request-changes 승계] 아래 설계는 그대로 옳지만 «키를 만드는 두 줄»이 윈도우에서 틀렸고, +형제 `#55` 와의 착지 상호작용을 아무도 적지 않았다 — 전말은 맨 아래 §정정 절에 있다.** + +## ⓐ 제안이 «지금도» 참인가 — 참이다. 단 수는 움직였다 + +제안의 수: 루트 `65×61 · 52×40 · 66×8 · 70×3 · 68×1` · `ldc` `55×4 · 52×7`. +★**착수 시 재측**(그 회차 이후 픽스처가 늘었다): + +| 디렉터리 | 지금 | +|---|---| +| `test-data/`(루트) | **52×40 · 65×62 · 66×8 · 68×1 · 70×3** | +| `test-data/ldc` | **52×7 · 55×6** | +| `test-data/indy` | **52×1 · 65×6** | +| `cp` · `dispatch/*` · `loader` · `src/jar` | 52×3 · 52×10 · 52×2 · 65×1 | + +**총 150 클래스**. ⇒ 「다섯 버전이 섞여 있다」는 **여전히 참**이고, ★**65 가 61→62 로 는 것은 이 리니지가 그 사이 픽스처를 더했기 때문**이다. + +## ⓑ 이미 핀하는 축 — 둘 있다. 그리고 «그 둘이 못 덮는 것»이 이 회차의 대상이다 + +1. `tests/test_fixture_pins.rs` — **`test-data/indy` 의 javac 산출물**만(`.java` 짝이 있는 것) `65.0` 으로 핀. +2. **생성기 코드** — `make_ldc_fixtures.py` 는 `major=52` 기본값 + 항목별 오버라이드(55)를 **소스에 박아** 둔다. + `cp`·`indy` 합성분도 같다. ⇒ 그 **16개**는 재생성해도 버전이 안 흔들린다(생성기를 고치면 diff 에 보인다). + +⇒ ★**남는 것은 «javac 로 손수 재컴파일할 수 있는» 132개**다(`.java` 소스 보유 · 실측). +★**그 132개가 정확히 사고가 일어난 자리**다 — 루트의 66×8·68×1·70×3 이 그 지문이다. + +## ⓒ 제안의 «비용 산정»을 바꿨다 — 기각이 아니라 «설계 교체» + +제안의 tradeoff: 「pinning means first **deciding the intended target** per fixture or per directory — +and any fixture **recompiled** to match would change bytes … **The decision is the work**; the assertion is ten lines.」 + +★**그 문장은 «통일»을 전제한다.** 그런데 제안이 산 것으로 적은 이득은 +「A regenerated fixture **cannot quietly start testing a different Java version's** bytecode shapes」 — +즉 ★**«드리프트 탐지»**이지 «버전 통일»이 아니다. + +⇒ ★★**목표를 «동결»로 바꾸면 그 「work」가 통째로 사라진다**: +**지금 값을 그대로 기록하고, 혼자 움직이면 red.** 재컴파일 **0** · 바이트 변경 **0** · +「어느 타깃이 옳은가」라는 **결정 자체가 불요**다. +★**대가는 정직하게 적는다**: 이 축은 ★**「버전이 섞여 있다」를 «고치지» 않는다.** 섞인 채로 **굳힌다**. +그것이 옳은 이유는 — 통일하려면 재컴파일해야 하고, ★**재컴파일은 바이트를 바꾸며 그 픽스처가 «무엇을 시험하는지»를 바꿀 수 있다**(제안 자신의 경고다). +⇒ ★**섞임은 «관측된 사실»로 표에 남고, 그 사실이 보이는 자리에 놓인다.** + +## 만든 것 + +- **`test-data/class-file-versions.txt`** — 150행의 `. <경로>` + 머리 주석 + (★「이것은 «타깃»이 아니라 «동결»이다」·왜 통일하지 않는가·무엇을 잡는가·어떻게 갱신하는가). +- **`test-data/src/record-class-file-versions.py`** — 표 재기록기. ★머리 주석을 **보존**하고(없으면 **거부**), + docstring 이 ★**「실패를 잠재우려고 돌리지 마라 — 왜 움직였는지 보고, 재컴파일과 «같은 커밋»에 표를 넣어라」**를 못박는다. +- **`tests/test_fixture_pins.rs`** — 새 테스트 `committed_fixtures_keep_their_recorded_class_file_version`. + +## ★세 방향을 «전부» 검사한다 — 이것이 이 설계의 급소다 + +| 방향 | 없으면 생기는 구멍 | +|---|---| +| 기록과 **다르다** | 드리프트를 못 잡는다(본래 목적) | +| **기록에 없는** 픽스처가 있다 | ★**핀이 «옵트인»이 되어 내일 추가되는 픽스처는 조용히 미보호** | +| **파일이 없는** 기록이 남았다 | 표가 썩어 「무엇이 보호되는지」를 아무도 못 믿는다 | + +★**둘째가 특히 중요하다** — 이 저장소가 반복해 잡은 「통과하지만 아무것도 재지 않는」 형태의 정확한 변종이다. + +**개악 대조(3방향 · 전건 red · 복원 green)** +- **M1** `Hello.class` 의 버전 바이트만 `65 → 70`(= 다른 JDK 로 재생성한 것과 **같은 형상**) → ★**red**: + `Hello.class: recorded 65.0, found 70.0` + 「재컴파일이 의도였다면 기록기를 돌려 표를 «같은 커밋»에 넣어라」 +- **M2** 기록 없는 새 `.class` 투입 → ★**red**(`fixtures with no recorded version: [...]`) +- **M3** 표에만 있는 유령 항목 추가 → ★**red**(`the table records fixtures that are no longer here: [...]`) +- 복원 → **green**(`test_fixture_pins` **2 passed**) + +★기록기 **멱등** 확인: 다시 돌려도 표가 **바이트 동일**. + +## 잃는 것 / 안 하면 무엇이 나쁜가 + +⒜**잃는 것**: ⑴**150행짜리 데이터 파일**이 는다 — 픽스처를 **의도적으로** 재컴파일할 때마다 **한 줄을 같이 고쳐야** 한다 +(★그것이 비용이자 «목적»이다: 그 한 줄이 리뷰에 보이는 유일한 흔적이다) ⑵`cargo test --all` **572 → 573**(테스트 1개) · +`test_fixture_pins` 소요 **0.04s**(파일 150개의 8바이트만 읽는다 — 파싱 없음) ⑶★**«섞여 있음»을 고치지 않는다**(위 ⓒ). +⑷★**생성기 산출물 16개는 «이중 잠금»이 된다**(생성기 코드 + 표) — 규칙을 단순하게 유지하려고 **일부러** 전건을 넣었다. +⒝**안 하면**: 제안이 적은 그 사고가 계속 가능하다 — `javac Switch.java` 를 `--release` 없이 돌리면 **오늘의 JDK(70)** 가 찍히고, +★**이진 파일 안 2바이트 변화는 리뷰에서 아무도 못 본다.** ★그리고 그것은 가정이 아니다 — ★**루트의 66·68·70 이 그 사고가 «이미 일어난» 흔적**이다. + +--- + +# §정정 (2026-09-17 · `rustjava-adopt-indy-fixture-jdk-pin-and-slot-accounting-p0-fix`) + +## ① 경로 구분자 — 윈도우에서 하위 36개가 전부 어긋났다 + +★**설계가 아니라 «키를 만드는 두 줄»이 틀렸다.** 표는 `indy/StringConcat.class` 를 담는데 +윈도우의 `to_string_lossy()` 는 `indy\StringConcat.class` 를 만든다 ⇒ ★**하위 디렉터리 36개가 +«미기록»과 «유령 기록» 양쪽에 «동시에» 걸린다.** ★**루트 114개는 통과**하므로 mac/linux 에서는 **절대 보이지 않는다** +— `rust_ci (windows-latest, {stable,beta})` 만 잡았다(모수 실측: **150 = 루트 114 + 하위 36**). + +★★**같은 결함이 «기록기»에도 있었다** — `record-class-file-versions.py` 가 윈도우에서 **역슬래시 행을 써 넣는다**. +★**한쪽만 고치면 방향만 바뀐다**: Rust 만 고치면 이번엔 **윈도우에서 기록한 표가 mac/linux 를 red** 로 만들고, +★**기록기는 CI 가 돌리지 않으므로 그 축은 «영영 조용하다».** ⇒ **두 곳을 같이 고쳤다** +(Rust: 구분자 정규화 · Python: `as_posix()`). + +★**표 자체는 이미 슬래시였다**(역슬래시 행 **0** · 실측) ⇒ 정규화만으로 맞는다. + +## ② 형제와의 상호작용 — «코드»가 아니라 «순서»였고, 그 사이 순서가 «정해졌다» + +`#55` 는 `test-data/indy/` 에 `.class` **3개를 추가**하면서 표를 만지지 않는다 ⇒ **텍스트 충돌은 0인데 +나중에 착지하는 쪽이 main 을 red** 로 만든다. ★**이것은 설계가 «의도한» 동작이다**(「기록에 없는 픽스처 = red」가 이 축의 목적). + +★**그 사이 `#55` 가 먼저 착지했다**(`4b89f18`) ⇒ ★**순서 문제는 «해소된 형태»로 나타났다**: +base 를 당기니 그 3개가 **정확히 red 로 잡혔고**(`fixtures with no recorded version: [NotFactoryDescriptor · +NotInvokeStaticFactory · NotMakeConcatWithConstants]`), ★**고친 기록기로 재기록하니 3행이 더해지고 그 외는 무변** +(150 → **153** · diff 가 정확히 3줄). ⇒ ★**기계가 설계대로 «잡고», 처방이 «한 명령»이었다는 실증이다.** + +★**남은 형제 실측**(이 브랜치 기준): `#56`·`#58` 과의 충돌은 **`REPORT.md`·`STATE.md` 뿐**이고 +★**둘 다 `.class` 를 만지지 않는다**(`#56` test-data 접촉 0 · `#58` 은 `test-data/src/verify-javac-fixtures.sh` 스크립트뿐) +⇒ ★**표와 상호작용하지 않는다.** 착지 순서 제약 **없음**. + +## ③ 형제 식별 오기 — `STATE.md` 에 적힌 「#55 는 원장 3파일만」 + +★**거짓이었고, 무해하지 않았다.** 그 한 줄 때문에 ②의 상호작용이 **보이지 않았다**. +⇒ 그 자리에서 정정했다(원문은 남기고 아래에 반증을 붙였다). ★**교훈: 「어느 파일을 만지나」는 «PR 파일 목록»으로 확인하라.** + +## 이 설계의 «본래 대가»를 흐리지 않는다 — 그리고 이제 «적어 뒀다» + +「기록에 없는 픽스처 = red」는 ★**의도된 엄격함**이고, 그래서 ★**픽스처를 더하는 모든 회차가 표를 함께 만져야 한다.** +그 비용이 **어디에도 적혀 있지 않았다**(실측: `AGENTS.md` 에 관련 문장 0) ⇒ ★**`AGENTS.md` §Testing Boundaries 에 한 줄**로 남겼다. + +## 검증 + +- ⒝⑴ **Rust**: `table_key()` 를 함수로 빼고 단위로 시험 — `dispatch\base\PackageBase.class` → `dispatch/base/PackageBase.class`. +- ⒝⑵ **Python**: 윈도우 없이 잰다 — `PureWindowsPath` 로 `str()` 은 `dispatch\base\…`, `as_posix()` 는 `dispatch/base/…` 임을 mac 에서 실증. +- ⒞ **개악**: 정규화 한 줄을 되돌리면 ★**mac 에서도 그 단위 시험이 진다**(`table_keys_use_forward_slashes_on_every_platform` FAILED) · 복원 green. + ★**그래서 «문자열 치환»으로 구현했다** — `components()` 로 join 하면 Unix 에서 역슬래시 입력이 그대로 통과해 ★**개악이 mac 에서 안 잡힌다.** + ★대가: Unix 파일명에 진짜 역슬래시가 있으면 바뀐다(픽스처엔 없고 표는 우리 것이다). +- ⒠ `cargo test --all --no-fail-fast` **0 failed**(수는 아래 본문). diff --git a/test-data/class-file-versions.txt b/test-data/class-file-versions.txt new file mode 100644 index 00000000..64a4ef67 --- /dev/null +++ b/test-data/class-file-versions.txt @@ -0,0 +1,173 @@ +# Class file version of every committed fixture, one per line: . . +# +# This is a freeze, not a target. These fixtures were compiled at different times with whatever JDK +# was installed, which is why five different majors appear below — 52 x40, 65 x62, 66 x8, 68 x1, +# 70 x3 in the root alone. Unifying them would mean recompiling, and recompiling changes bytes, +# which can change what a fixture exercises. So the rule here is only "it must not move on its own". +# +# What that catches: regenerating a fixture with a different JDK. `javac Switch.java` with no +# --release stamps the running JDK's version, and a two-byte change inside a committed binary is +# not something anyone notices in review. The majors 66, 68 and 70 above are that accident already +# having happened. +# +# Changing a number here is allowed and sometimes correct — but it has to be a line in a diff that +# someone chose to write, next to the recompiled fixture, rather than something that slipped in. +# +# Regenerate this file (after a deliberate recompile) with: +# python3 test-data/src/record-class-file-versions.py +66.0 Abstract$Base.class +66.0 Abstract$Derived.class +66.0 Abstract.class +65.0 Array.class +70.0 ArrayCovariance.class +52.0 ArrayEdgeCases.class +70.0 ArrayStore.class +52.0 BooleanTest.class +52.0 CheckCast.class +52.0 ClassMetadata$ByteLoader.class +52.0 ClassMetadata$First.class +52.0 ClassMetadata$Payload.class +52.0 ClassMetadata$Sample.class +52.0 ClassMetadata$Second.class +52.0 ClassMetadata.class +65.0 ClinitFailure$Bad.class +65.0 ClinitFailure$BadError.class +65.0 ClinitFailure.class +52.0 CollectionsInvokeInterface.class +65.0 ConcurrentClinit$Failing.class +65.0 ConcurrentClinit$FailingReader.class +65.0 ConcurrentClinit$Successful.class +65.0 ConcurrentClinit$SuccessfulReader.class +65.0 ConcurrentClinit.class +65.0 Constants.class +65.0 ConstantsReader.class +65.0 ControlFlow.class +65.0 CurrentThread$1.class +65.0 CurrentThread.class +65.0 DoubleInit.class +66.0 Dup.class +65.0 Exception.class +65.0 Field.class +52.0 FieldTypes.class +65.0 FileErrors.class +65.0 FileLength.class +65.0 ForName.class +52.0 FormatterIntegration$1.class +52.0 FormatterIntegration$FailingAppendable.class +52.0 FormatterIntegration$SnapshotSequence.class +52.0 FormatterIntegration.class +65.0 Hello.class +65.0 IncompatibleStatic.class +65.0 Instanceof.class +52.0 IntegerOverflow.class +66.0 Interface$IInterface.class +66.0 Interface$Implementation.class +66.0 Interface.class +65.0 InterfaceCast$Base.class +65.0 InterfaceCast$Derived.class +65.0 InterfaceCast$IBase.class +65.0 InterfaceCast$IFace.class +65.0 InterfaceCast.class +65.0 LazyClinit$Base.class +65.0 LazyClinit$Derived.class +65.0 LazyClinit$GetstaticTarget.class +65.0 LazyClinit$IFace.class +65.0 LazyClinit$Impl.class +65.0 LazyClinit$NewTarget.class +65.0 LazyClinit$PutstaticTarget.class +65.0 LazyClinit$SelfRef.class +65.0 LazyClinit.class +52.0 LongDouble.class +65.0 Method.class +52.0 MonitorSemantics$1.class +52.0 MonitorSemantics$CounterWorker.class +52.0 MonitorSemantics$FailingWorker.class +52.0 MonitorSemantics.class +66.0 MultiArray.class +52.0 NativeMethod.class +52.0 NullArgGuards.class +52.0 NullBufferGuards.class +52.0 NullFileIoGuards.class +52.0 NullHandling.class +52.0 NullNetGuards$Handler.class +52.0 NullNetGuards$JarConn.class +52.0 NullNetGuards.class +52.0 NullSpecGuards.class +65.0 OddEven.class +52.0 Pop2Test.class +68.0 ReferenceCompare.class +65.0 StaleHolder.class +65.0 StaticFlag.class +65.0 StaticInherit$Base.class +65.0 StaticInherit$IConst.class +65.0 StaticInherit$Impl.class +65.0 StaticInherit$Sub.class +65.0 StaticInherit.class +65.0 StaticInitTrigger$Base.class +65.0 StaticInitTrigger$Sub.class +65.0 StaticInitTrigger.class +65.0 StaticOrder$Helper.class +65.0 StaticOrder.class +65.0 StringConcat.class +70.0 StringIntern.class +65.0 SuperClass$InnerClass.class +65.0 SuperClass$InnerDerivedClass.class +65.0 SuperClass.class +65.0 SurrogateChars.class +65.0 Switch.class +52.0 ThreadInterruption$DaemonWorker.class +52.0 ThreadInterruption$JoinWorker.class +52.0 ThreadInterruption$PreInterruptedSleepWorker.class +52.0 ThreadInterruption$TargetWorker.class +52.0 ThreadInterruption$WaitWorker.class +52.0 ThreadInterruption.class +65.0 ThrowableCause.class +65.0 TimeApi.class +52.0 TypeConversion.class +65.0 UnsupportedCharset.class +52.0 VirtualDispatchSemantics.class +65.0 WideLocals.class +65.0 ZipCorrupt.class +52.0 ZipGuards.class +52.0 cp/UnreferencedTag13.class +52.0 cp/UnreferencedTag14.class +52.0 cp/UnreferencedTag19.class +52.0 dispatch/base/PackageBase.class +52.0 dispatch/base/ProtectedBridge.class +52.0 dispatch/base/PublicBridge.class +52.0 dispatch/base/SamePackageChild.class +52.0 dispatch/foreign/ForeignChild.class +52.0 dispatch/foreign/ForeignProtectedGrandchild.class +52.0 dispatch/foreign/ForeignPublicGrandchild.class +52.0 dispatch/interfaces/InheritedOperation.class +52.0 dispatch/interfaces/InterfaceImplementation.class +52.0 dispatch/interfaces/InterfaceOwner.class +65.0 indy/ConstantKinds$Op.class +65.0 indy/ConstantKinds$Suit.class +65.0 indy/ConstantKinds.class +65.0 indy/Lambda$Op.class +65.0 indy/Lambda.class +52.0 indy/MakeConcat.class +52.0 indy/MakeConcatWithArgument.class +52.0 indy/MakeConcatWrongDescriptor.class +52.0 indy/NotFactoryDescriptor.class +52.0 indy/NotInvokeStaticFactory.class +52.0 indy/NotMakeConcatWithConstants.class +52.0 indy/NotStringConcatFactory.class +65.0 indy/StringConcat.class +55.0 ldc/Ldc2WDynamic.class +52.0 ldc/Ldc2WMethodType.class +55.0 ldc/LdcDynamic.class +55.0 ldc/LdcDynamicBSMArgPastEnd.class +55.0 ldc/LdcDynamicBSMIndexPastEnd.class +55.0 ldc/LdcDynamicDuplicateBSM.class +55.0 ldc/LdcDynamicNoBSM.class +52.0 ldc/LdcDynamicOldMajor.class +52.0 ldc/LdcMethodHandle.class +52.0 ldc/LdcMethodType.class +52.0 ldc/LdcTag13.class +52.0 ldc/LdcTag14.class +52.0 ldc/LdcUnknownTag.class +52.0 loader/Base.class +52.0 loader/Payload.class +65.0 src/jar/JarTest.class diff --git a/test-data/src/record-class-file-versions.py b/test-data/src/record-class-file-versions.py new file mode 100755 index 00000000..67ce0a3d --- /dev/null +++ b/test-data/src/record-class-file-versions.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Rewrite `test-data/class-file-versions.txt` from what is on disk. + +Run this *after* deliberately recompiling a fixture, so the recorded version moves in the same +commit as the bytes it describes. Running it to silence a failing pin without looking at why the +version moved defeats the point — the check exists because a changed version is easy to miss +inside a binary diff. + +Usage: python3 test-data/src/record-class-file-versions.py +""" + +import struct +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TABLE = ROOT / "class-file-versions.txt" + + +def main(): + old = TABLE.read_text().splitlines(keepends=True) if TABLE.exists() else [] + header = [line for line in old if line.startswith("#")] + if not header: + print(f"{TABLE} has no header comment to preserve; refusing to write", file=sys.stderr) + return 1 + + rows = [] + for path in sorted(ROOT.rglob("*.class")): + data = path.read_bytes() + minor, major = struct.unpack(">H", data[4:6])[0], struct.unpack(">H", data[6:8])[0] + # as_posix(), not str(): on Windows the latter writes `indy\\StringConcat.class`, and the + # table would then be wrong for everyone else. The recorder is not run by CI, so that + # direction of the bug would never announce itself. + rows.append(f"{major}.{minor} {path.relative_to(ROOT).as_posix()}\n") + + TABLE.write_text("".join(header) + "".join(rows)) + print(f"recorded {len(rows)} fixtures in {TABLE}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_fixture_pins.rs b/tests/test_fixture_pins.rs index b3708d8e..cbd14257 100644 --- a/tests/test_fixture_pins.rs +++ b/tests/test_fixture_pins.rs @@ -5,7 +5,7 @@ //! check cannot ask the compiler what it would produce. The only thing that can notice a //! regeneration on a different toolchain is an assertion on the bytes that are actually committed. -use std::{ffi::OsStr, fs, path::Path}; +use std::{collections::BTreeMap, ffi::OsStr, fs, path::Path}; /// Fixtures under `test-data/indy` that have a `.java` source next to them were produced with: /// @@ -77,3 +77,102 @@ fn indy_javac_fixtures_keep_the_pinned_class_file_version() { // the source-pairing rule stops matching anything. assert!(!checked.is_empty(), "no javac-compiled fixture found under test-data/indy"); } + +/// Every committed fixture's class file version, frozen in `test-data/class-file-versions.txt`. +/// +/// The pin above says what `test-data/indy` *should* be and why; this one says only that nothing +/// moves without someone writing it down. That is the weaker claim on purpose: the rest of +/// `test-data` was never compiled to one target — 52, 65, 66, 68 and 70 all appear — and unifying +/// it would mean recompiling, which changes bytes and can change what a fixture exercises. +/// +/// All three directions are checked, because a pin that only compares the fixtures it already +/// knows about is opt-in, and opt-in pins rot: a fixture added tomorrow would simply not be +/// covered, silently. +#[test] +fn committed_fixtures_keep_their_recorded_class_file_version() { + let table = fs::read_to_string("test-data/class-file-versions.txt").unwrap(); + let mut recorded = table + .lines() + .filter(|line| !line.starts_with('#') && !line.trim().is_empty()) + .map(|line| { + let (version, path) = line.split_once(' ').expect("each row is `. `"); + (path.to_owned(), version.to_owned()) + }) + .collect::>(); + assert!(!recorded.is_empty(), "the version table is empty; it should list every fixture"); + + let mut unrecorded = Vec::new(); + let mut moved = Vec::new(); + for path in class_files(Path::new("test-data")) { + let relative = table_key(path.strip_prefix("test-data").unwrap()); + let bytes = fs::read(&path).unwrap(); + let actual = format!( + "{}.{}", + u16::from_be_bytes([bytes[6], bytes[7]]), + u16::from_be_bytes([bytes[4], bytes[5]]) + ); + + match recorded.remove(&relative) { + None => unrecorded.push(relative), + Some(expected) if expected != actual => moved.push(format!("{relative}: recorded {expected}, found {actual}")), + Some(_) => {} + } + } + + assert!( + moved.is_empty(), + "class file version changed without the table changing with it: {moved:#?}\n\ + If the recompile was deliberate, run test-data/src/record-class-file-versions.py and \ + commit the table next to the fixture." + ); + assert!( + unrecorded.is_empty(), + "fixtures with no recorded version: {unrecorded:#?}\n\ + Run test-data/src/record-class-file-versions.py to add them." + ); + assert!( + recorded.is_empty(), + "the table records fixtures that are no longer here: {:#?}\n\ + Run test-data/src/record-class-file-versions.py to drop them.", + recorded.keys().collect::>() + ); +} + +/// The table is checked in, so its keys have to read the same on every OS: `indy/StringConcat.class`, +/// never `indy\StringConcat.class`. +/// +/// Windows built the latter and every one of the 36 fixtures in a subdirectory then showed up as +/// both "unrecorded" and "a ghost record" at once, while the 114 in the root passed — which is why +/// this was invisible on macOS and Linux and only `rust_ci (windows-latest, *)` caught it. +/// +/// Taking the lossy string and replacing separators (rather than joining `components()`) is what +/// makes the rule testable without Windows: the unit test below feeds it a backslash path, which a +/// `components()` walk on Unix would hand back unchanged. The cost is that a Unix filename +/// containing a literal backslash would be rewritten — no fixture has one, and the table is ours. +fn table_key(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +#[test] +fn table_keys_use_forward_slashes_on_every_platform() { + assert_eq!( + table_key(Path::new("dispatch\\base\\PackageBase.class")), + "dispatch/base/PackageBase.class" + ); + assert_eq!(table_key(Path::new("indy/StringConcat.class")), "indy/StringConcat.class"); + assert_eq!(table_key(Path::new("Hello.class")), "Hello.class"); +} + +fn class_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + out.extend(class_files(&path)); + } else if path.extension() == Some(OsStr::new("class")) { + out.push(path); + } + } + out.sort(); + out +}