diff --git a/REPORT.md b/REPORT.md index 77fef896..6faef6eb 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,15 @@ # REPORT +## [2026-09-17] base 를 당겼다 — ★**막고 있던 코드 충돌은 «이미 없었다»**(rustjava-adopt-link-stringconcatfactory-p1-fix2) +- 무엇을: `origin/main` 당김(뒤처짐 **9**) + 그 당김이 만든 `test-data/class-file-versions.txt` **3행**. ★제품 코드 **0줄** · 픽스처 바이트 **불변**. +- ★★**전제가 반증됐다**: 이 회차는 「`make_indy_fixtures.py` 4구역 코드 충돌」을 풀라고 발권됐는데, 지금 당기면 그 파일은 **충돌하지 않는다**. `-p1-fix` 회차가 **14:10 에 `0f06b93f` 로 이미 합집합 해소**했고 게이트②가 **15:43 에 그 head 를 approve** 했다 — 발권 근거였던 12:12 blocked 회신이 그 사이 **낡았다**. +- ★**재발도 불가능하다**(그냥 「지금은 없다」가 아니다): 뒤진 9커밋 중 `make_indy_fixtures.py` 를 만진 것이 **0건**이다. +- ★**그래도 합집합이 «진짜»인지 다시 쟀다** — 합집합의 전형적 실패는 「한쪽 의도가 조용히 빠지는 것」이라서다. + ⒜생성기를 **실제로 돌려** 10개 픽스처가 전부 **바이트 불변**(양쪽 가족 — theirs `MakeConcat*` 3 · ours `RecipeWants*` 3 — 을 **한 생성기**가 낸다). + ⒝★**양방향 개악**: ours 산출물 3개만 치우면 **ours 만 red**(theirs 2건 green) · theirs 3개만 치우면 **theirs 2건 red**(ours green). ⇒ 「선택」이 아니라 합집합이다. +- ★**남은 것은 충돌이 아니라 «부채»였다**: base 를 당기면 #57 의 버전 표가 들어오고 이 PR 의 픽스처 3개가 미등재라 `test_fixture_pins` 가 red 가 된다(게이트② 검수가 「`-merge` 회차가 표 3행을 함께 진다」고 이미 지목한 그것). 생성기로 채웠다 — ★**`3 0`(삭제 0)**. +- 원장 충돌 2건(`REPORT.md`·`STATE.md`)은 **합집합·시간순**. ★`STATE.md` 「진행중」 한 줄은 3-way 에서 **ours 가 이겼다**(base == theirs ⇒ 정상) — 결손이 아니다. +- 검증: `test_class_format` **16/0** · `test_fixture_pins` **3/0** · `cargo test --all` **578 / 0 / 1** · DoD **7명령 전건 rc=0**. + ## [2026-09-17] 부트스트랩 정적 인자는 «적재 가능 상수»여야 한다 — 경계에서 «종류»로 (rustjava-adopt-bound-bootstrap-static-arguments-p0) - 무엇을: 채택 제안 `2026-09-16-bound-bootstrap-static-arguments#p0`. ★**제품 동작이 바뀐다** — 인자가 적재 불가 상수를 가리키는 클래스 파일이 **`ClassFormatError`** 로 거부된다. - 왜: JVMS 4.7.23 이 요구하는 것은 «인덱스가 어딘가에 닿는다»가 아니라 ★**「적재 가능 상수」**다(Integer·Float·Long·Double·Class·String·MethodHandle·MethodType·Dynamic). @@ -13,6 +24,27 @@ - 검증: `cargo test --all` **575 passed / 0 failed / 1 ignored** · DoD 7명령 rc=0 · ★**새 픽스처 0**(기존 파일 바이트 패치 · 길이 필드 불변). - ★**여기서 더 갈 수 없는 자리도 적는다**: `Dynamic` 인자의 서술자가 필드 서술자인지, `MethodHandle` 인자가 실제 멤버로 해석되는지는 **payload 가 필요**해 이 술어의 밖이다(설계이지 누락이 아니다). - ★후속: `ClassFormatError` 에 **사유를 실어라**(M) — OpenJDK 는 인덱스와 이유를 말한다. ★p2-fix 회차가 낸 같은 제안과 **묶어서** 하는 편이 낫다. +## [2026-09-17] 레시피가 콜사이트와 어긋날 때 — ★**「싸고 옳다」는 두 겹으로 거짓이었다** (rustjava-adopt-link-stringconcatfactory-p1) +- 무엇을: 채택 제안 `2026-09-16-link-stringconcatfactory#p1`(「진단의 자리를 정하라」). ★**제품 동작이 바뀐다** — + 레시피와 콜사이트의 «합의»를 **변환 전에 한 번** 재고, 없던 `java/lang/BootstrapMethodError` 를 런타임에 추가했다. +- 왜: 제안은 「현 런타임 검사는 싸고 옳다, 문제는 «자리»뿐」이라 했다. ★**둘 다 틀렸다.** + ⑴★**그 가지는 애초에 던지지 못했다** — `java/lang/BootstrapMethodError` 가 이 런타임에 **없어서** + `jvm.rs:948` 의 unwrap 에서 **NoClassDefFoundError 로 패닉**했다. 이 형상의 픽스처가 **하나도 없어** 아무도 밟은 적이 없다. + ⑵★**검사가 «부족분»만 봤다** — 레시피가 콜사이트보다 **짧으면** 남는 인자를 조용히 버리고 **틀린 문자열**(`a`)을 돌려주고 rc=0 이었다. + ⇒ 부등호를 **상등**으로 바꾸고 인자·상수 두 축을 함께 잰다. +- 사용자 영향: 손상·수제 클래스 파일이 **패닉이나 조용한 오답 대신** `BootstrapMethodError` 를 받는다. 정상 javac 산출물은 **무영향**. +- ★★**제안의 처방(`classfile/validation.rs` 로 옮겨 `ClassFormatError`)은 기각한다 — 추측이 아니라 실측이다.** + OpenJDK 26.0.1 에 세 픽스처를 **직접 돌렸다**: 전건 `BootstrapMethodError`(원인 `StringConcatException`) · 프레임은 `linkCallSite` = + ★**링크 시점**이고 ★**`ClassFormatError` 가 아니다**. 파일은 파싱되고, 부트스트랩 정적 인자의 «의미»는 클래스파일 형식의 소관이 아니다. + ⇒ 제안이 스스로 적은 비용(「검증 단계에서 부트스트랩 인자를 걷는 것 = `attribute.rs` 가 일부러 피한 해결」)도 함께 면했다. +- ★**왜 «변환 전»인가**: 여기엔 `CallSite` 가 없어 링크가 첫 실행에 접힌다 ⇒ 그 순서에 가장 가까운 것이 「무엇도 변환하기 전에 잰다」이다. + 먼저 변환하면 `String.valueOf` 를 통해 **사용자 `toString()` 이 돌고**, 그 예외가 이 진단을 덮는다. +- ★★**개악 4종 전건 red**(정상 574 green): M1 합의 검사 제거 · M2 `!=`→`>`(부족분만) · M3 상수 축 제거 · + ★**M4 `loader.rs` 에서 클래스 등록 제거 → `jvm.rs:948` 패닉이 «되살아난다»**(= 새 클래스가 하중을 진다). +- 검증: `cargo test --all` **573 → 574 passed / 0 failed / 1 ignored**(기준선은 `origin/main` 워크트리에서 실측) · + DoD 7명령 rc=0 · 픽스처 재생성 **멱등**(기존 4개 바이트 불변). +- ★후속: **72개** `java/…Error|Exception` 이름이 이 워크스페이스의 오류 경로에 있고, 이번 회차 전까지 그중 **1개**(이 건)가 proto 없이 있었다. + ★**지금 baseline 이 0** 이라 잠그기 가장 싼 시점이다 — `docs/worklog/2026-09-17-string-concat-recipe-arity.json`. ## [2026-09-17] `StringConcatFactory.makeConcat` 도 링크한다 — 단 «이유는 제안이 적은 것이 아니다» (rustjava-adopt-link-stringconcatfactory-p0) - 무엇을: 레시피 없는 진입점 `makeConcat` 을 링크한다. ★**실행기 무접촉** — 콜사이트 인자 수로 **레시피를 합성**한다. diff --git a/STATE.md b/STATE.md index 0279cc77..24f4e80a 100644 --- a/STATE.md +++ b/STATE.md @@ -1,15 +1,48 @@ # STATE ## 진행중 -(없음 — 2026-09-16 실측: 착수 시 진행 티켓 0 · 열린 PR 0. ※「열린 PR 0」은 ★**이 회차 PR 착지 시점 기준**이다 — 회신 시점에는 그 PR 자신이 열려 있다) +(없음 — 2026-09-17 실측: 착수 시 열린 PR **4건**(#56·#57·#58·#59). ★**내 경로와 겹치는 것은 둘**이다 — + #59 가 `test-data/src/indy/make_indy_fixtures.py`·`tests/test_class_format.rs` · #56 이 `tests/test_class_format.rs`. + ★`jvm-bytecode/src/interpreter.rs`·`rustjava-runtime/` 은 **겹침 0**. ⇒ 착지 순서는 **#56·#59 먼저, 이 PR 나중**이 맞다 + (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-adopt-link-stringconcatfactory-p1-fix2] ★★**base 당김 — 그런데 막고 있던 코드 충돌은 «이미 없었다»(PR #60).** + ★**전제 반증**: 「`make_indy_fixtures.py` 4구역 충돌」은 `-p1-fix` 가 **14:10 `0f06b93f`** 로 합집합 해소했고 게이트②가 **15:43 그 head 를 approve** 했다. + 발권 근거(12:12 blocked 회신)가 그 사이 낡은 것이다. ★**재발 불가**도 확인 — 뒤진 9커밋 중 그 파일을 만진 것 **0건**. + ★**합집합이 진짜인지 다시 쟀다**: 생성기 재실행이 10픽스처 **바이트 불변**(양쪽 가족을 한 생성기가 낸다) · + ★**양방향 개악** — ours 산출물만 치우면 ours 만 red · theirs 만 치우면 theirs 2건만 red ⇒ 「선택」이 아니다. + ★**실제로 남아 있던 것은 부채**다: 당기면 #57 의 표가 들어와 이 PR 픽스처 3개가 미등재 ⇒ 생성기로 **3행**(★삭제 0) 등재. + ★원장 2파일 합집합·시간순 · `--all` **578/0/1** · DoD 7명령 rc=0. + ★★**게이트③ 착지 — PR #60 · `--merge`**(등재 repo `contracts/upstream-sync-repos.conf:22` · 티켓 `merge_strategy: merge` 선언분 ⇒ ★**계보 보존**). + 게이트② **approve**(리니지 최신 회신 `…-p1-fix2.review.md`) · 핀 **`632c6b06`** ↔ 착수 시 PR head **동일**(불이동) · + ★**`MERGEABLE/CLEAN` · base 뒤처짐 «0»** ⇒ 충돌 해소·base 당김 **둘 다 불요**(앞 회차가 이미 당겼다). + ★핀에서 `ci-presence` **rc=0 CI_GREEN** · 자식 PR **0건** · 배포 **0**(이 저장소에 배포 워크플로 없음) · 주기 자동 커밋 **0건** · 라이브 실행 주체 **없음**. + ★**동봉은 이 기록 한 줄뿐** — 원장(worklog 쌍·`STATE`·`REPORT`)은 구현·승계 회차가 이미 실었다. - [rustjava-adopt-bound-bootstrap-static-arguments-p0] ★★**부트스트랩 정적 인자 = «적재 가능 상수» — 경계에서 «종류»로.** 채택 제안 `2026-09-16-bound-bootstrap-static-arguments#p0`(worklog json `adoptedProposals` 기록). ★**제품 동작 변경 있음.** ★**제안이 적은 위험을 먼저 쟀다**(「틀리면 람다가 전부 corrupt」): 태그 검사는 payload 를 읽지 않으므로 `attribute.rs` 설계와 충돌하지 않고, ★**커밋된 클래스 파싱이 전/후 «144/12 동일»**(새로 거부 0). ★OpenJDK 26 은 같은 파일을 `ClassFormatError: argument_index 4 has bad constant type` 로 거부한다. ★**안 하면**: 링커에서 `UnsupportedOperationException` — 「파손」을 「미지원」이라 말하게 된다. ★개악 2종 red(존재만 되돌리기 · ★집합에 Utf8 한 칸 추가) · `--all` **575/0/1** · 새 픽스처 **0**(바이트 패치). +- [rustjava-adopt-link-stringconcatfactory-p1] ★★**레시피가 콜사이트와 어긋날 때 — 제안의 「싸고 옳다」가 두 겹으로 거짓이었다.** + 채택 제안 `2026-09-16-link-stringconcatfactory#p1`(worklog json `adoptedProposals` 기록). ★**제품 동작 변경 있음.** + ★**제안의 처방은 기각**(`classfile/validation.rs`/`ClassFormatError`) — ★**OpenJDK 26.0.1 에 픽스처 3종을 직접 돌린 실측**이 근거다: + 전건 `BootstrapMethodError`(원인 `StringConcatException`) · 프레임 `linkCallSite` = **링크 시점** · **`ClassFormatError` 아님**. + ★★**실제로 깨져 있던 둘**: ⑴`java/lang/BootstrapMethodError` 가 **런타임에 없어서** 그 가지가 던지는 대신 + `jvm.rs:948` unwrap 에서 **패닉**했다(픽스처 0 이라 아무도 밟은 적이 없다) ⑵검사가 **부족분**만 봐서 + 레시피가 **짧으면** 남는 인자를 버리고 **틀린 문자열을 반환**했다(rc=0). ⇒ **상등 검사 · 변환 전 1회 · 인자/상수 두 축**. + ★**변환 전인 이유**: `String.valueOf` 가 **사용자 `toString()`** 을 돌리므로, 먼저 변환하면 그 예외가 진단을 덮는다. + ★**개악 4종 전건 red**(M1 검사 제거 · M2 `!=`→`>` · M3 상수 축 제거 · ★**M4 클래스 등록 제거 → 패닉 재현**) · + `cargo test --all` **573 → 574 / 0 failed / 1 ignored**(기준선 워크트리 실측) · DoD 7명령 rc=0 · 픽스처 재생성 멱등. + ★★**게이트③ 착지 — PR #60 · `--merge`**(등재 repo · `merge_strategy: merge` 선언분). 게이트② **approve** · + 핀 `532d98d7` **불이동**(착수 실측 2026-09-17T01:18:24Z) · ★**`MERGEABLE/CLEAN` · base 뒤처짐 «0»** ⇒ 충돌 해소·base 당김 **둘 다 불요**. + ★핀에서 `ci-presence` **rc=0 CI_GREEN** · 자식 PR **0건** · 배포 **0**(이 저장소에 배포 워크플로 없음) · 주기 자동 커밋 **0건**. + ★**동봉은 이 기록 한 줄뿐** — 원장(worklog 쌍·`STATE`·`REPORT`)은 구현 회차가 이미 실었다. + ★★**착지 순서 고지 — 이 회차가 «새 `.class` 3개»를 들여온다**(`RecipeWants{MoreArguments,FewerArguments,AConstant}`). + 열린 PR **#57**(ci-pending)이 「`.class` 는 `test-data/class-file-versions.txt` 에 등재돼야 하고 ★**미등재는 핀이 실패시킨다**」를 세운다 + ⇒ ★**이 PR 이 먼저 착지하면 #57 의 표에 이 셋이 «없어»** 그 회차가 red 가 된다(해소 = `python3 test-data/src/record-class-file-versions.py` 재생성). + ★**텍스트 충돌이 0 이라 `mergeable` 로는 보이지 않는 종류다.** - [rustjava-adopt-link-stringconcatfactory-p0] ★★**`StringConcatFactory.makeConcat` 도 링크한다 — 단 «이유는 제안이 적은 것이 아니다».** 채택 제안 `2026-09-16-link-stringconcatfactory#p0`(worklog json `adoptedProposals` 기록). ★**제품 동작 변경**: `makeConcat` 콜사이트가 **거부 대신 실행**된다. ★**실행기(`concat_with_constants`)는 한 줄도 안 바뀌었다.** diff --git a/docs/worklog/2026-09-17-base-pull-and-stale-block-premise.json b/docs/worklog/2026-09-17-base-pull-and-stale-block-premise.json new file mode 100644 index 00000000..33b6cb2a --- /dev/null +++ b/docs/worklog/2026-09-17-base-pull-and-stale-block-premise.json @@ -0,0 +1,39 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-17", + "taskId": "rustjava-adopt-link-stringconcatfactory-p1-fix2", + "summary": "This round was issued to union-resolve a four-region code conflict in test-data/src/indy/make_indy_fixtures.py that blocked PR #60's gate-3 merge. The conflict no longer exists: the -p1-fix round resolved it at 14:10 in 0f06b93f and gate 2 approved that head at 15:43, two hours after the blocked report the ticket was written from. What was actually left was a different thing wearing the same clothes — pulling base brings in PR #57's class-file-version table, and this PR's three fixtures are not in it, so the merged tree is red. Pulled base, backfilled the three rows, and re-verified that the existing union really is a union.", + "changes": [ + "merge origin/main (9 behind): only REPORT.md and STATE.md conflicted; both are ledger files, resolved as a chronological union", + "test-data/class-file-versions.txt: +3 rows for RecipeWantsMoreArguments/FewerArguments/AConstant, written by the recorder" + ], + "verification": [ + "premise re-measured rather than trusted: `git merge origin/main` on the current head conflicts in REPORT.md and STATE.md only — make_indy_fixtures.py auto-merges", + "and it cannot come back from this base: of the 9 commits behind, zero touch test-data/src/indy/make_indy_fixtures.py", + "timeline established from commit times rather than from the ticket: blocked report 12:12 at head 38efee33 -> -p1-fix pushed 0f06b93f at 14:10 ('merge origin/main — resolved one code conflict as a union') -> gate 2 approved pin 0f06b93f at 15:43; 38efee33 is an ancestor of 0f06b93f", + "union is really a union, not a selection — both sides' entry points are present in one file: recipe_arity_call_site (ours, #60) at :108 and MAKECONCAT_DESCRIPTOR/make_concat_call_site (theirs, #59) at :156,:159, with both fixture families in the same table", + "generator re-run writes all 10 fixtures byte-identical (git reports no change), so one generator produces both sides' outputs unchanged", + "bidirectional mutation M1: move only ours' three RecipeWants*.class away -> test_a_recipe_that_contradicts_its_call_site_is_a_bootstrap_method_error red, both of theirs' tests green", + "bidirectional mutation M2: move only theirs' three MakeConcat*.class away -> test_the_recipe_free_factory_concatenates_every_argument and test_each_axis_of_the_factory_identity_is_observable red, ours green; restore leaves bytes unchanged", + "table backfill: git diff --numstat -> `3\t0` (zero deletions = no fixture was regenerated), and the three added paths are set-identical to the three .class files this PR adds", + "ledger conflict preservation proved line-wise both ways; the one line reported missing from the main side is STATE.md's 진행중 paragraph, which the 3-way merge correctly took from ours (base == theirs for that line)", + "cargo test --test test_class_format: 16 passed / 0 failed; test_fixture_pins: 3 passed / 0 failed; cargo test --all: 578 passed / 0 failed / 1 ignored", + "DoD all 7 commands rc=0" + ], + "issues": [ + "The ticket's stated head (38efee33) was two commits stale at dispatch. Nothing in the pipeline notices that a blocked report's premise was superseded by a later push to the same PR.", + "This round moves the head off the gate-2 pin 0f06b93f, so PR #60 needs re-review — unavoidable, since the base pull is what the merge round could not do itself (the version table is not a ledger file, so merge-template 2-c(a) does not authorise touching it)." + ], + "adoptedProposals": [], + "proposals": [ + { + "title": "Let a blocked report say which head it measured, and check that before re-issuing from it", + "plainSummary": "A round can be told to fix a problem that someone else already fixed two hours earlier, because the ticket is written from a report that has since gone stale.", + "userBenefit": "None directly. It stops a worker slot being spent re-deriving that there is nothing to do.", + "why": "The blocked report for PR #60 did record its head (38efee33) — correctly and prominently. The gap is that nothing compares that recorded head against the PR's current head at issuing time, so the ticket repeated a four-region conflict table that had already been resolved and approved. The comparison is one API call and the report already carries the input for it.", + "tradeoff": "It only catches the case where the head moved. A premise can also go stale because main moved underneath it — as happened here in the other direction, where pulling base created a new blocker that had nothing to do with the recorded conflict. So this would have caught this instance and would not catch the general problem, which risks reading as a fix when it is a narrowing.", + "effort": "S", + "target": "~/orchestrator (ticket issuing), not this repo" + } + ] +} diff --git a/docs/worklog/2026-09-17-base-pull-and-stale-block-premise.md b/docs/worklog/2026-09-17-base-pull-and-stale-block-premise.md new file mode 100644 index 00000000..4ab97c8a --- /dev/null +++ b/docs/worklog/2026-09-17-base-pull-and-stale-block-premise.md @@ -0,0 +1,67 @@ +# 2026-09-17 — base 당김 · ★전제 반증 (rustjava-adopt-link-stringconcatfactory-p1-fix2) + +이 회차는 **「`test-data/src/indy/make_indy_fixtures.py` 4구역 코드 충돌을 합집합으로 풀어라」**로 발권됐다. +★**그 충돌은 없다.** + +## 반증 — 시각으로 재구성한다 + +| 시각 | 무슨 일 | +|---|---| +| 12:12 | `-p1-merge` 회차가 head **`38efee33`** 에서 `blocked`(코드 충돌 4구역) | +| ★**14:10** | `-p1-fix` 회차가 **`0f06b93f`** 푸시 — 커밋 제목 그대로 「merge origin/main — **코드 충돌 1건을 «합집합»으로 해소한다**」 | +| ★**15:43** | 게이트②가 **그 head `0f06b93f` 를 approve** | + +⇒ ★**발권 근거(12:12 회신)가 그 사이 낡았다.** `38efee33` 은 `0f06b93f` 의 **조상**이다. + +지금 다시 재면: +``` +git merge origin/main # base 뒤처짐 9 +UU REPORT.md ← 원장 +UU STATE.md ← 원장 +(make_indy_fixtures.py 는 충돌하지 않는다) +``` +★**재발도 불가능하다** — 뒤진 9커밋 중 그 파일을 만진 것이 **0건**이다(「지금은 없다」가 아니라 「이 base 에서는 날 수 없다」). + +## 그래도 합집합이 «진짜»인지 다시 쟀다 + +합집합의 전형적 실패는 **한쪽 의도가 조용히 빠지는 것**이다. 승인된 head 위에서 세 축으로 확인했다. + +**⒜ 한 파일에 양쪽 진입점이 다 있다** +`recipe_arity_call_site`(ours · #60) `:108` · `MAKECONCAT_DESCRIPTOR` `:156` + `make_concat_call_site`(theirs · #59) `:159`, +픽스처 표에 **두 가족이 함께** 있다(`MakeConcat*` 3 · `RecipeWants*` 3). + +**⒝ 생성기를 실제로 돌렸다** — 10개 픽스처 **전건 바이트 불변**. ★**한 생성기가 양쪽 산출물을 낸다**는 것이 합집합의 실질이다. + +**⒞ 양방향 개악** + +| 개악 | ours 테스트 | theirs 테스트 | +|---|---|---| +| **M1** ours 산출물 3개만 치움 | ★**red** | green ×2 | +| **M2** theirs 산출물 3개만 치움 | green | ★**red ×2** | + +⇒ 「선택」이 아니라 **합집합**이다. (복원 후 바이트 불변 확인.) + +## 실제로 남아 있던 것 — 충돌이 아니라 «부채» + +base 를 당기면 **#57** 의 `test-data/class-file-versions.txt` 가 들어오고, +이 PR 의 픽스처 3개가 미등재라 `test_fixture_pins` 가 **red** 가 된다. +★게이트② 검수가 이미 「`-merge` 회차가 base 당김 + **표 3행**을 함께 진다」로 지목한 그것이다. + +``` +python3 test-data/src/record-class-file-versions.py +git diff --numstat test-data/class-file-versions.txt → 3 0 +``` +★**삭제 0** = 기존 픽스처 재생성 0. 추가 3행의 경로는 이 PR 이 추가한 3개 `.class` 와 **집합 동일**. + +## 원장 충돌 + +`REPORT.md`·`STATE.md` 최상단 삽입 2건 — **합집합·시간순**(main 측 17:09 → ours 14:10). +줄 단위 양방향 보존 확인. ★한 줄이 「main 측 결손」으로 잡히는데, 그것은 `STATE.md` 의 「진행중」 문단이고 +**base == theirs** 라 3-way 가 **ours 를 택한 정상 결과**다 — 결손이 아니다. + +## 검증 +`test_class_format` **16/0** · `test_fixture_pins` **3/0** · `cargo test --all` **578 / 0 / 1** · DoD **7명령 전건 rc=0**. + +## 대가 +★이 회차가 head 를 게이트② 핀 `0f06b93f` 에서 **움직인다** ⇒ PR #60 은 **재검 대상**이다. +피할 수 없다 — 버전 표는 원장 파일이 아니라서 머지 템플릿 2-c⒜ 가 `-merge` 회차에게 그것을 허용하지 않는다. diff --git a/docs/worklog/2026-09-17-string-concat-recipe-arity.json b/docs/worklog/2026-09-17-string-concat-recipe-arity.json new file mode 100644 index 00000000..6c2163a9 --- /dev/null +++ b/docs/worklog/2026-09-17-string-concat-recipe-arity.json @@ -0,0 +1,46 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-17", + "taskId": "rustjava-adopt-link-stringconcatfactory-p1", + "summary": "Decide what a linked recipe that contradicts its call site should do: keep it a BootstrapMethodError (what OpenJDK 26 throws, measured), raise it before anything is converted, and compare the counts for equality rather than shortfall — plus add the java.lang.BootstrapMethodError the runtime did not have, without which that branch panicked instead of throwing.", + "changes": [ + "jvm-bytecode/src/interpreter.rs: one agreement check (arguments and constants) before any conversion, replacing the two mid-recipe shortfall guards", + "rustjava-runtime/src/classes/java/lang/bootstrap_method_error.rs: the class itself, a LinkageError child as in the JDK; registered in lang.rs and loader.rs", + "test-data/src/indy/make_indy_fixtures.py: recipe_arity_call_site — a call site that is the factory but whose recipe contradicts it", + "test-data/indy/RecipeWants{MoreArguments,FewerArguments,AConstant}.class: one fixture per direction of the disagreement", + "tests/test_class_format.rs: test_a_recipe_that_contradicts_its_call_site_is_a_bootstrap_method_error" + ], + "verification": [ + "OpenJDK 26.0.1 on all three fixtures: BootstrapMethodError caused by StringConcatException, at linkage — not ClassFormatError", + "mutation: drop the agreement check -> red; weaken != to > -> red; drop the constants half -> red; unregister the class -> red (the original panic returns)", + "cargo test --all: 574 passed / 0 failed / 1 ignored (origin/main baseline 573 / 0 / 1, measured in a worktree)", + "DoD 7 commands all rc=0; indy fixture regeneration idempotent (the four pre-existing fixtures unchanged)" + ], + "issues": [ + "The proposal's premise that 'the current runtime check is cheap and correct' was false twice: the branch could not throw at all (no java.lang.BootstrapMethodError in the runtime, so jvm.rs unwrapped a NoClassDefFoundError and panicked), and it only looked for a shortfall, so a recipe shorter than the call site silently dropped arguments and returned the wrong string.", + "Nothing had ever executed that branch — there was no fixture for a linked-but-contradictory recipe until this round added one, which is why a panicking error path stayed green." + ], + "adoptedProposals": [ + "2026-09-16-link-stringconcatfactory#p1" + ], + "proposals": [ + { + "title": "Lock 'every exception class the Rust code names is one the runtime can load'", + "plainSummary": "The runtime panicked instead of throwing because it raised an exception class it does not have; a script can check that never happens again.", + "userBenefit": "An error path that names a missing class fails as a panic, which is the one failure mode a JVM must not have — and it stays invisible until something walks that path.", + "why": "Measured this round: 72 java/javax class names appear in jvm.exception(...)-shaped call sites across the workspace, and before this round exactly one of them (BootstrapMethodError) had no proto in rustjava-runtime. The baseline is now 0, which is what makes a lock cheap: it is a grep for the names and a grep for the protos, in the shape of scripts/check-worklog-json.py.", + "tradeoff": "A name built at runtime (format!) would be invisible to the grep, so the check is a floor rather than a proof; and it adds an eighth DoD command, which the parity checker then also has to carry.", + "effort": "S", + "target": "scripts/, .github/workflows/rust.yml, CLAUDE.md" + }, + { + "title": "Give the linkage errors their cause, once java.lang.invoke exists", + "plainSummary": "A real JVM reports the underlying StringConcatException as the cause of the BootstrapMethodError; here the message carries that text instead.", + "userBenefit": "Stack traces read as they would on a real JVM, and a test could assert the cause rather than a substring of the message.", + "why": "OpenJDK 26 throws BootstrapMethodError('bootstrap method initialization exception') caused by StringConcatException('Mismatched number of concat arguments: ...'). This round folded that detail into the message because there is no java.lang.invoke package here to name the cause.", + "tradeoff": "Only worth doing alongside the LambdaMetafactory work that brings java.lang.invoke in; on its own it would add a class that exists solely to be a cause.", + "effort": "S", + "target": "rustjava-runtime/src/classes/java/lang/invoke/, jvm-bytecode/src/interpreter.rs" + } + ] +} diff --git a/docs/worklog/2026-09-17-string-concat-recipe-arity.md b/docs/worklog/2026-09-17-string-concat-recipe-arity.md new file mode 100644 index 00000000..3de05434 --- /dev/null +++ b/docs/worklog/2026-09-17-string-concat-recipe-arity.md @@ -0,0 +1,92 @@ +# 2026-09-17 — A recipe that contradicts its call site + +`taskId: rustjava-adopt-link-stringconcatfactory-p1` · +adopts `2026-09-16-link-stringconcatfactory#p1` + +The proposal asked a question rather than prescribing a fix: when a linked recipe's arity +disagrees with the call site, is a runtime `BootstrapMethodError` the right diagnosis, or should +this move to `classfile`'s validation as a `ClassFormatError`? It offered its own answer — +"the current runtime check is cheap and correct; the question is only whether the diagnosis is in +the right place." + +Both halves of that sentence turned out to be false. + +## What OpenJDK does, measured rather than argued + +There was no fixture for this shape, so three were assembled (`recipe_arity_call_site` in +`test-data/src/indy/make_indy_fixtures.py`) and run on OpenJDK 26.0.1: + +``` +$ java -cp . RecipeWantsFewerArguments +Exception in thread "main" java.lang.BootstrapMethodError: bootstrap method initialization exception + at java.base/java.lang.invoke.BootstrapMethodInvoker.invoke(BootstrapMethodInvoker.java:187) + at java.base/java.lang.invoke.CallSite.makeSite(CallSite.java:310) + at RecipeWantsFewerArguments.main(Unknown Source) +Caused by: java.lang.invoke.StringConcatException: Mismatched number of concat arguments: recipe wants 1 arguments, but signature provides 2 + at java.base/java.lang.invoke.StringConcatFactory.argumentMismatch(StringConcatFactory.java:470) +``` + +All three fixtures behave the same way: `BootstrapMethodError`, caused by `StringConcatException`, +and the frames say `linkCallSite` — the call site body never ran. + +So the proposal's suggested move is **rejected**: this is not a `ClassFormatError`. The file +parses, and the class file format has nothing to say about what a bootstrap's static arguments +mean; it is a linkage error, and moving it to parse time would have made this runtime disagree +with the JVM it is imitating — and would have cost exactly what the proposal's own tradeoff line +predicted (walking bootstrap arguments during validation, which `attribute.rs` deliberately +avoids). The `where` half of the proposal's question therefore had a cheaper answer than either +option it listed. + +## What was actually broken + +**⑴ The branch could not throw.** `java.lang.BootstrapMethodError` does not exist in this runtime. +`jvm.exception("java/lang/BootstrapMethodError", …)` resolves the class, fails, and `jvm.rs:948` +unwraps that failure — so the error path **panicked** with a `NoClassDefFoundError` instead of +throwing. The first fixture written for it hit exactly that: + +``` +thread '…' panicked at jvm/src/jvm.rs:948:94: +called `Result::unwrap()` on an `Err` value: JavaException(ClassInstance(java/lang/NoClassDefFoundError)) +``` + +It stayed green for as long as it did because nothing had ever executed the branch: a recipe can +only contradict its call site in a hand-built file, and no such file existed. The class is now +there — a `LinkageError` child, as in the JDK — and mutation M4 below re-creates the panic, so it +is load-bearing rather than merely present. + +**⑵ The check only looked for a shortfall.** It fired when the recipe ran out of arguments. The +opposite direction — a recipe *shorter* than the call site — is invisible to a guard shaped that +way: the loop simply never asks for the surplus arguments, so `RecipeWantsFewerArguments` printed +a quietly wrong `a` and exited 0. A wrong answer, where the direction the proposal named produced +a refusal. So the comparison became an equality, on both axes (arguments and constants), which is +also what `StringConcatFactory` itself does. + +## Why it is checked before anything is converted + +There is no `CallSite` to link here, so "linkage" collapses into the first execution of the +opcode; the closest this design can come to OpenJDK's ordering is to compare the counts before +converting anything. That is not cosmetic: conversion runs `String.valueOf`, which calls user +`toString()`, so converting first means running user code on a call site already known to be +malformed — and letting that code's exception replace this diagnosis. + +## Mutation matrix + +Product-side mutations, each reverted after measuring. Unmutated: 574 passed / 0 failed. + +| | mutation | result | +|---|---|---| +| M1 | delete the agreement check | **red** — all three fixtures run and print | +| M2 | `!=` weakened to `>` (shortfall only) | **red** — `RecipeWantsFewerArguments` prints `a` | +| M3 | drop the constants half of the condition | **red** — `RecipeWantsAConstant` prints `a` | +| M4 | unregister `BootstrapMethodError` from `loader.rs` | **red** — the panic at `jvm.rs:948` returns | + +`cargo test --all`: **573 → 574** passed / 0 failed / 1 ignored (baseline measured on +`origin/main` in a separate worktree). DoD's seven commands rc=0. Regenerating the indy fixtures +leaves the four pre-existing ones byte-identical. + +## For the next round + +The lock worth having is not about string concatenation: **72** `java/…Error|Exception` names +appear in this workspace's error paths, and before this round exactly one of them had no proto in +`rustjava-runtime` — each such name is a panic waiting for the first execution of its branch. The +baseline is now 0, which is the cheapest moment to nail it down. See `proposals` in the `.json`. diff --git a/jvm-bytecode/src/interpreter.rs b/jvm-bytecode/src/interpreter.rs index af758058..e907e179 100644 --- a/jvm-bytecode/src/interpreter.rs +++ b/jvm-bytecode/src/interpreter.rs @@ -1015,37 +1015,57 @@ impl Interpreter { /// /// Recipe grammar (JVMS-adjacent, `StringConcatFactory` javadoc): `\u{1}` consumes the next /// argument, `\u{2}` consumes the next constant, anything else is literal text. + /// + /// ## Why the counts are compared before anything is concatenated + /// + /// The recipe, the call site descriptor and the bootstrap's static arguments are three accounts + /// of the same concatenation, written by the same compiler. Disagreement means the file is + /// wrong — but wrong in a way the *class file format* has nothing to say about, so this stays a + /// `BootstrapMethodError` rather than moving to `classfile`'s validation as a `ClassFormatError`. + /// Measured rather than argued: OpenJDK 26 refuses all three shapes under + /// `test-data/indy/RecipeWants*.class` with `BootstrapMethodError`, caused by + /// `StringConcatException`, and refuses them at *linkage* — before the call site body runs. + /// + /// There is no `CallSite` to link here, so linkage collapses into the first execution; the + /// closest this can come to that ordering is to check before converting anything, which is what + /// it does. Converting first would run user `toString()` through `String.valueOf` and could + /// raise that call's exception instead of this diagnosis. + /// + /// It is an equality, not a shortfall. A recipe *shorter* than the call site would otherwise + /// drop the surplus arguments and return a quietly wrong string, which a guard that only fires + /// when the recipe runs out of arguments cannot see at all. async fn concat_with_constants(jvm: &Jvm, call_site: &StringConcatCallSite, params: Vec) -> Result> { + let wanted_params = call_site.recipe.chars().filter(|&x| x == '\u{1}').count(); + let wanted_constants = call_site.recipe.chars().filter(|&x| x == '\u{2}').count(); + if wanted_params != params.len() || wanted_constants != call_site.constants.len() { + return Err(jvm + .exception( + "java/lang/BootstrapMethodError", + &format!( + "string concat recipe wants {wanted_params} arguments and {wanted_constants} constants, but the call site provides {} and the bootstrap {}", + params.len(), + call_site.constants.len() + ), + ) + .await); + } + let mut result = String::new(); let mut params = params.into_iter(); let mut constants = call_site.constants.iter(); for character in call_site.recipe.chars() { + // Both `next()` calls yield `Some`: the counts above were taken from this same recipe. match character { '\u{1}' => { - // `params` is exactly as long as the descriptor says, and the recipe is the - // factory's own account of that descriptor, so a missing argument means the - // two disagree — a class file we mis-read rather than a runtime condition. - let Some(param) = params.next() else { - return Err(jvm - .exception( - "java/lang/BootstrapMethodError", - "string concat recipe wants more arguments than the call site has", - ) - .await); - }; - result += &Self::value_to_string(jvm, param).await?; + if let Some(param) = params.next() { + result += &Self::value_to_string(jvm, param).await?; + } } '\u{2}' => { - let Some(constant) = constants.next() else { - return Err(jvm - .exception( - "java/lang/BootstrapMethodError", - "string concat recipe wants more constants than the bootstrap has", - ) - .await); - }; - result += constant; + if let Some(constant) = constants.next() { + result += constant; + } } _ => result.push(character), } diff --git a/rustjava-runtime/src/classes/java/lang.rs b/rustjava-runtime/src/classes/java/lang.rs index 4482399d..921884a4 100644 --- a/rustjava-runtime/src/classes/java/lang.rs +++ b/rustjava-runtime/src/classes/java/lang.rs @@ -5,6 +5,7 @@ mod arithmetic_exception; mod array_index_out_of_bounds_exception; mod array_store_exception; mod boolean; +mod bootstrap_method_error; mod byte; mod char_sequence; mod character; @@ -74,10 +75,11 @@ mod virtual_machine_error; pub use self::{ abstract_method_error::AbstractMethodError, abstract_string_builder::AbstractStringBuilder, appendable::Appendable, arithmetic_exception::ArithmeticException, array_index_out_of_bounds_exception::ArrayIndexOutOfBoundsException, - array_store_exception::ArrayStoreException, boolean::Boolean, byte::Byte, char_sequence::CharSequence, character::Character, class::Class, - class_cast_exception::ClassCastException, class_circularity_error::ClassCircularityError, class_format_error::ClassFormatError, - class_loader::ClassLoader, class_not_found_exception::ClassNotFoundException, clone_not_supported_exception::CloneNotSupportedException, - cloneable::Cloneable, comparable::Comparable, double::Double, r#enum::Enum, error::Error, exception::Exception, + array_store_exception::ArrayStoreException, boolean::Boolean, bootstrap_method_error::BootstrapMethodError, byte::Byte, + char_sequence::CharSequence, character::Character, class::Class, class_cast_exception::ClassCastException, + class_circularity_error::ClassCircularityError, class_format_error::ClassFormatError, class_loader::ClassLoader, + class_not_found_exception::ClassNotFoundException, clone_not_supported_exception::CloneNotSupportedException, cloneable::Cloneable, + comparable::Comparable, double::Double, r#enum::Enum, error::Error, exception::Exception, exception_in_initializer_error::ExceptionInInitializerError, float::Float, illegal_access_error::IllegalAccessError, illegal_access_exception::IllegalAccessException, illegal_argument_exception::IllegalArgumentException, illegal_monitor_state_exception::IllegalMonitorStateException, illegal_state_exception::IllegalStateException, diff --git a/rustjava-runtime/src/classes/java/lang/bootstrap_method_error.rs b/rustjava-runtime/src/classes/java/lang/bootstrap_method_error.rs new file mode 100644 index 00000000..ec0c497f --- /dev/null +++ b/rustjava-runtime/src/classes/java/lang/bootstrap_method_error.rs @@ -0,0 +1,39 @@ +use alloc::vec; + +use jvm::{ClassInstanceRef, Jvm, Result}; +use jvm_class_proto::JavaMethodProto; +use jvm_types::{ClassAccessFlags, MethodAccessFlags}; + +use crate::{RuntimeClassProto, RuntimeContext, classes::java::lang::String}; + +// class java.lang.BootstrapMethodError +pub struct BootstrapMethodError; + +impl BootstrapMethodError { + pub fn as_proto() -> RuntimeClassProto { + RuntimeClassProto { + name: "java/lang/BootstrapMethodError", + parent_class: Some("java/lang/LinkageError"), + interfaces: vec![], + methods: vec![ + JavaMethodProto::new("", "()V", Self::init, MethodAccessFlags::PUBLIC), + JavaMethodProto::new("", "(Ljava/lang/String;)V", Self::init_with_message, MethodAccessFlags::PUBLIC), + ], + fields: vec![], + access_flags: ClassAccessFlags::PUBLIC, + } + } + + async fn init(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef) -> Result<()> { + tracing::debug!("java.lang.BootstrapMethodError::({this:?})"); + + jvm.invoke_special(&this, "java/lang/LinkageError", "", "()V", ()).await + } + + async fn init_with_message(jvm: &Jvm, _: &mut RuntimeContext, this: ClassInstanceRef, message: ClassInstanceRef) -> Result<()> { + tracing::debug!("java.lang.BootstrapMethodError::({this:?}, {message:?})"); + + jvm.invoke_special(&this, "java/lang/LinkageError", "", "(Ljava/lang/String;)V", (message,)) + .await + } +} diff --git a/rustjava-runtime/src/loader.rs b/rustjava-runtime/src/loader.rs index 1e6c312f..abd25d5f 100644 --- a/rustjava-runtime/src/loader.rs +++ b/rustjava-runtime/src/loader.rs @@ -53,6 +53,7 @@ pub fn get_runtime_class_proto(name: &str) -> Option { crate::classes::java::lang::ArrayIndexOutOfBoundsException::as_proto(), crate::classes::java::lang::ArrayStoreException::as_proto(), crate::classes::java::lang::Boolean::as_proto(), + crate::classes::java::lang::BootstrapMethodError::as_proto(), crate::classes::java::lang::Class::as_proto(), crate::classes::java::lang::ClassCastException::as_proto(), crate::classes::java::lang::ClassCircularityError::as_proto(), diff --git a/test-data/class-file-versions.txt b/test-data/class-file-versions.txt index 64a4ef67..55fe6b5f 100644 --- a/test-data/class-file-versions.txt +++ b/test-data/class-file-versions.txt @@ -154,6 +154,9 @@ 52.0 indy/NotInvokeStaticFactory.class 52.0 indy/NotMakeConcatWithConstants.class 52.0 indy/NotStringConcatFactory.class +52.0 indy/RecipeWantsAConstant.class +52.0 indy/RecipeWantsFewerArguments.class +52.0 indy/RecipeWantsMoreArguments.class 65.0 indy/StringConcat.class 55.0 ldc/Ldc2WDynamic.class 52.0 ldc/Ldc2WMethodType.class diff --git a/test-data/indy/RecipeWantsAConstant.class b/test-data/indy/RecipeWantsAConstant.class new file mode 100644 index 00000000..b85bf1a6 Binary files /dev/null and b/test-data/indy/RecipeWantsAConstant.class differ diff --git a/test-data/indy/RecipeWantsFewerArguments.class b/test-data/indy/RecipeWantsFewerArguments.class new file mode 100644 index 00000000..8b99a952 Binary files /dev/null and b/test-data/indy/RecipeWantsFewerArguments.class differ diff --git a/test-data/indy/RecipeWantsMoreArguments.class b/test-data/indy/RecipeWantsMoreArguments.class new file mode 100644 index 00000000..7896c98a Binary files /dev/null and b/test-data/indy/RecipeWantsMoreArguments.class differ diff --git a/test-data/src/indy/make_indy_fixtures.py b/test-data/src/indy/make_indy_fixtures.py index 482322a2..5b116dba 100644 --- a/test-data/src/indy/make_indy_fixtures.py +++ b/test-data/src/indy/make_indy_fixtures.py @@ -105,6 +105,54 @@ def near_miss_call_site(name, bootstrap_class, bootstrap_name, bootstrap_descrip ) +def recipe_arity_call_site(name, recipe, arguments): + """A call site that *is* the factory, but whose recipe and descriptor disagree. + + The recipe and the call site descriptor are two accounts of the same concatenation, written + by the same compiler, so javac cannot emit this — only a corrupt or hand-built file can, which + is why it is assembled here. `recipe` is the bootstrap's static argument; `arguments` are the + strings the call site actually pushes, and its descriptor is derived from them. + + The class prints the result, so a disagreement the runtime fails to notice shows up as the + wrong text rather than as nothing at all.""" + cp = Pool() + this_class = cp.klass(name) + super_class = cp.klass("java/lang/Object") + main_name, main_desc, code_name = cp.utf8("main"), cp.utf8("([Ljava/lang/String;)V"), cp.utf8("Code") + + bootstrap = cp.add( + u1(15) # kind 6 = REF_invokeStatic, as the real factory is + + u1(6) + + u2(cp.methodref(cp.klass(FACTORY_CLASS), cp.name_and_type("makeConcatWithConstants", FACTORY_DESCRIPTOR))) + ) + descriptor = "(" + "Ljava/lang/String;" * len(arguments) + ")Ljava/lang/String;" + call_site = cp.add(u1(18) + u2(0) + u2(cp.name_and_type("concat", descriptor))) + out = cp.fieldref(cp.klass("java/lang/System"), cp.name_and_type("out", "Ljava/io/PrintStream;")) + println = cp.methodref(cp.klass("java/io/PrintStream"), cp.name_and_type("println", "(Ljava/lang/String;)V")) + recipe_index = cp.string(recipe) + + body = u1(0xB2) + u2(out) # getstatic System.out + for argument in arguments: + body += u1(0x12) + u1(cp.string(argument)) # ldc argument + body += ( + u1(0xBA) + u2(call_site) + u2(0) # invokedynamic concat(String...)String + + u1(0xB6) + u2(println) # invokevirtual println(String)V + + b"\xb1" # return + ) + code_attr = u2(1 + len(arguments)) + u2(1) + u4(len(body)) + body + u2(0) + u2(0) + method = u2(0x0009) + u2(main_name) + u2(main_desc) + u2(1) + u2(code_name) + u4(len(code_attr)) + code_attr + + bootstrap_body = u2(1) + u2(bootstrap) + u2(1) + u2(recipe_index) # one method, the recipe + class_attributes = [u2(cp.utf8("BootstrapMethods")) + u4(len(bootstrap_body)) + bootstrap_body] + + return ( + b"\xca\xfe\xba\xbe" + u2(0) + u2(52) + cp.bytes() + + u2(0x0021) + u2(this_class) + u2(super_class) + + u2(0) + u2(0) + u2(1) + method + + u2(len(class_attributes)) + b"".join(class_attributes) + ) + + MAKECONCAT_DESCRIPTOR = "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;" @@ -221,6 +269,20 @@ class prints the result, so a test can assert *what* was concatenated rather tha "MakeConcatWithArgument.class": ("MakeConcatWithArgument", "a", "b", None, 1), } +# Linked bootstraps whose recipe contradicts the call site, one fixture per direction. OpenJDK 26 +# refuses both at linkage with BootstrapMethodError (StringConcatException: "Recipe and method type +# do not match"), before any argument is converted — measured, not assumed. +RECIPE_ARITY = { + # Recipe wants two arguments, the call site pushes one. + "RecipeWantsMoreArguments.class": ("RecipeWantsMoreArguments", "\u0001\u0001", ["a"]), + # The other direction, which no mid-recipe guard can see: the recipe runs out first, so the + # second argument is silently dropped and the concatenation quietly returns the wrong string. + "RecipeWantsFewerArguments.class": ("RecipeWantsFewerArguments", "\u0001", ["a", "b"]), + # The same disagreement on the constants axis: \u0002 consumes a constant the bootstrap did not + # carry. Held by the same comparison, so it is a fixture rather than a second mechanism. + "RecipeWantsAConstant.class": ("RecipeWantsAConstant", "\u0001\u0002", ["a"]), +} + if __name__ == "__main__": OUT.mkdir(parents=True, exist_ok=True) for filename, args in FIXTURES.items(): @@ -229,3 +291,6 @@ class prints the result, so a test can assert *what* was concatenated rather tha for filename, args in LINKED.items(): (OUT / filename).write_bytes(make_concat_call_site(*args)) print(f"wrote {OUT / filename}") + for filename, args in RECIPE_ARITY.items(): + (OUT / filename).write_bytes(recipe_arity_call_site(*args)) + print(f"wrote {OUT / filename}") diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 3b68fc82..8b47244f 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -168,6 +168,42 @@ async fn test_each_axis_of_the_factory_identity_is_observable() { } } +// A bootstrap that *is* the factory, but whose recipe contradicts the call site it was linked to. +// javac cannot produce this — the recipe and the descriptor are two accounts of the same +// concatenation, written by the same compiler — so the fixtures are hand-assembled +// (test-data/src/indy/make_indy_fixtures.py). +// +// What the diagnosis should be was measured rather than chosen: OpenJDK 26 refuses all three with +// `BootstrapMethodError` caused by `StringConcatException`, at linkage. So it is neither a +// `ClassFormatError` (the class file format has nothing to say about bootstrap argument semantics, +// and the file parses) nor `UnsupportedOperationException` (the bootstrap *is* linked; the file is +// what is wrong). +// +// `RecipeWantsFewerArguments` is the direction a guard that fires when the recipe runs out of +// arguments cannot see: before this check, it concatenated the arguments the recipe did ask for +// and printed a quietly wrong "a" instead of refusing. +#[tokio::test] +async fn test_a_recipe_that_contradicts_its_call_site_is_a_bootstrap_method_error() { + for (name, disagreement) in [ + ("RecipeWantsMoreArguments", "recipe wants two arguments, the call site provides one"), + ("RecipeWantsFewerArguments", "recipe wants one argument, the call site provides two"), + ("RecipeWantsAConstant", "recipe wants a constant the bootstrap did not carry"), + ] { + let path = PathBuf::from(format!("test-data/indy/{name}.class")); + + let err = run_class(&path, &[Path::new("./test-data/indy/")], &[]).await.unwrap_err().to_string(); + + assert!( + err.contains("java.lang.BootstrapMethodError"), + "{name} ({disagreement}): expected the linkage diagnosis, got: {err}" + ); + assert!( + !err.contains("ClassFormatError") && !err.contains("UnsupportedOperationException"), + "{name}: the file parses and the bootstrap is one we link, got: {err}" + ); + } +} + #[tokio::test] async fn test_bad_magic_raises_class_format_error() { let mut bytes = hello_class();