From 976acae1289eb4b57b537be72521910f61af176a Mon Sep 17 00:00:00 2001 From: jun0 Date: Fri, 18 Sep 2026 00:21:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[rustjava-adopt-classfile-error-cause-decis?= =?UTF-8?q?ion-p0]=20fix(classfile):=20=EA=B1=B0=EB=B6=80=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EB=A5=BC=20=EC=84=B8=20=EC=B8=B5=EC=97=90=20=EA=BF=B4?= =?UTF-8?q?=EB=8B=A4=20=E2=80=94=20validate=5Fclass=20=EB=A5=BC=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EB=A7=88=EB=8B=A4=20=EC=AA=BC=EA=B0=A0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- REPORT.md | 12 +++ STATE.md | 6 ++ classfile/src/class.rs | 6 +- classfile/src/error.rs | 11 ++- classfile/src/validation.rs | 88 +++++++++++++------ classfile/tests/test.rs | 53 ++++++++--- .../2026-09-18-classfile-error-cause.json | 35 ++++++++ .../2026-09-18-classfile-error-cause.md | 59 +++++++++++++ jvm-bytecode/src/error.rs | 4 +- src/runtime.rs | 2 +- test-utils/src/lib.rs | 2 +- tests/test_class_format.rs | 63 +++++++++++-- 12 files changed, 285 insertions(+), 56 deletions(-) create mode 100644 docs/worklog/2026-09-18-classfile-error-cause.json create mode 100644 docs/worklog/2026-09-18-classfile-error-cause.md diff --git a/REPORT.md b/REPORT.md index ba0867df..b56f7b21 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,16 @@ # REPORT +## [2026-09-18] 거부된 클래스 파일이 «왜»를 말한다 — 세 층을 관통하는 사유 (rustjava-adopt-classfile-error-cause-decision-p0) +- 무엇을: 채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. ★**제품 동작 변경 있음** — `ClassFormatError` 메시지가 **모든 거부에 같던 「Invalid class file」** 에서 **사유별 문장**으로 바뀐다. +- ★**제안이 스스로 all-or-nothing 이라 못박았다** — 타입만 고치면 **관측되는 것이 없고**, `||` 사슬을 안 쪼개면 **평평함이 사라지는 게 아니라 옮겨갈 뿐**이다. 넷 다 했다: + `ClassFileError::InvalidFormat(&'static str)` → `ClassDefinitionError::InvalidClassFile(&'static str)`(★`From` 이 **버리던** 자리) → 두 경계 자리 모두 사유를 그대로 던진다 · `validate_class` 의 **8항 `||` 사슬 → 규칙마다 `if` 하나**(사유 **13개** · 필드 `ConstantValue` 는 「몇 개냐」와 「타입이 맞냐」가 **한 조건에 묶여** 있어 갈랐다). +- ★**왜 «변형»이 아니라 «문자열»인가**: 집합이 **열려 있고**(규칙마다 하나) **아무도 분기하지 않는다**. 선례도 있다 — `ClassDefinitionError::UnsupportedFeature(&'static str)`. +- ★★**사유를 꿰자마자 «평평한 오류가 가리고 있던 것 둘»이 나왔다**: + ⑴**테스트가 «어느 층이 거부하는지»를 틀리게 믿고 있었다** — 「인덱스가 엉뚱한 종류를 가리킨다」는 **검증**이 아니라 ★**파서**가 거부한다(`truncated or unparsable class file`). ★**코드를 추측에 맞추지 않고 단언을 실측에 맞췄다**(주석에 「measured, not assumed」). + ⑵**술어 이름이 낡아 있었다** — `bootstrap_method_static_arguments_are_in_the_pool` 은 이름과 달리 **「적재 가능 상수인가」까지** 요구한다(직전 회차가 넓혔고 자기 docstring 이 그렇게 적는다). 사유는 **규칙 그대로** 적고 ★**함수 이름은 바꾸지 않았다**(리팩터 = 범위 밖). +- ★★**양방향 — 세 층 «전부»에 개악**: **M1** `src/runtime.rs` 가 다시 문자열을 박는다 → red · **M2** `From` 이 다시 사유를 버린다(제안이 지목한 그 버그) → red · **M3** 두 사유를 한 문자열로 접는다 → ★**dedup 단언이 잡는다**(이게 없으면 「전부 같은 문자열로 되돌려도 통과」가 된다) · 복원 **17/0**. +- ★★**대가 — 실측한 구멍 하나를 포함해 적는다**: ⒜★**마지막 홉이 «두 번» 쓰여 있고 한 쪽만 테스트가 본다** — `test-utils/src/lib.rs` 사본만 개악하면 `cargo test --all` 이 **579 passed / 0 failed**(아무것도 안 운다). ★**합치는 것은 리팩터라 하지 않았고 구멍을 보고한다.** ⒝사유가 문자열이라 **두 규칙에 같은 문구**를 주는 것을 막는 것이 없다(dedup 단언은 세 픽스처만 덮는다) ⒞★**픽스처 규율을 대체하지 않는다**(제안이 이미 적었다) ⒟★**PR #66 과 같은 함수를 만진다** — 뒤에 착지하는 쪽이 base 를 당겨 그 항을 다시 쪼갠다(충돌은 실재하나 **기계적**). +- 검증: `cargo test --all` **578 → 579 / 0 failed / 1 ignored** · `classfile` **15+13/0** · DoD 7명령 rc=0. + ## [2026-09-17] ldc 픽스처를 ASM 으로 재생성하자 — ★**기각**(rustjava-adopt-ldc-tags-real-world-generator-survey-p0) - 무엇을: 운영자가 tower 에서 채택한 제안 `2026-09-16-ldc-tags-real-world-generator-survey#p0` 의 처분. ★**제품 코드 0줄** — 산출물은 «판정과 그 근거»다. - ★**기각 사유 ⑴ 제안이 사려는 것이 이미 있다 — 그것도 더 센 오라클로**: ★**OpenJDK 26.0.1 이 양성 픽스처 4건을 «실행»한다**(`LdcMethodHandle`·`LdcMethodType`·`LdcDynamic`·`Ldc2WDynamic` **전건 rc=0**) ⇒ 진짜 JVM 의 **검증기**가 우리 손조립 바이트를 받아들인다. 대조군(위법 5건)은 **전건 rc=1**. 그리고 「ASM 이 이 태그를 내는가」는 **선행 조사 회차가 이미 동적으로 실증**했다. diff --git a/STATE.md b/STATE.md index c4235efe..ed6066af 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,12 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-adopt-classfile-error-cause-decision-p0] ★★**거부 사유를 세 층에 꿴다 — 「Invalid class file」 하나가 13개 문장이 된다.** 채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. ★**제품 동작 변경 있음**(사용자가 보는 `ClassFormatError` 메시지). + ★제안이 **all-or-nothing** 이라 못박은 넷을 다 했다: `InvalidFormat(&'static str)` · `InvalidClassFile(&'static str)`(★`From` 이 **버리던** 자리) · 경계 2자리 · ★**`validate_class` 8항 `||` → 규칙마다 `if`**. + ★★**사유를 꿰자 «평평한 오류가 가리던 것 둘»이 나왔다**: ⑴테스트가 **어느 층이 거부하는지를 틀리게 믿었다**(검증 아닌 **파서**) ⇒ ★단언을 실측에 맞췄다 ⑵술어 **이름이 낡아 있었다**(「in_the_pool」인데 **적재 가능성까지** 본다) ⇒ 사유는 규칙대로, ★**이름은 안 바꿨다**(리팩터 금지). + ★**양방향 — 세 층 전부 개악**: M1 경계 · M2 `From` 이 사유 버림 · M3 두 사유를 한 문자열로 접음(★dedup 단언이 잡는다) · 복원 17/0. + ★★**대가**: ★**마지막 홉이 두 번 쓰여 있고 `test-utils` 사본은 «무검증»**(개악해도 579/0 · **합치지 않고 보고**) · 사유가 문자열이라 같은 문구 중복을 막는 것이 없다 · ★**PR #66 과 같은 함수**(충돌은 기계적). + ★`--all` **578 → 579/0/1** · DoD 7명령 rc=0. - [rustjava-adopt-ldc-tags-real-world-generator-survey-p0] ★★**「ldc 픽스처를 ASM 으로 재생성」 제안 — 기각.** ★**제품 코드 0줄**(`declinedProposals` 기록). ★**사유 ⑴ 더 센 오라클이 이미 있다** — ★**OpenJDK 26.0.1 이 양성 4건을 실행한다(전건 rc=0)** · 위법 5건은 전건 rc=1 · 「ASM 이 낸다」는 선행 회차가 이미 동적 실증. ★**⑵ 손의 흔적은 «옮겨갈» 뿐이다**(ASM 도 드라이버 15줄이 모양을 고른다) ★**⑶ 생성기가 «두 기구»가 된다**(음성 픽스처는 ASM 불가) · diff --git a/classfile/src/class.rs b/classfile/src/class.rs index 2d6a7c23..e30e36b8 100644 --- a/classfile/src/class.rs +++ b/classfile/src/class.rs @@ -100,12 +100,12 @@ impl ClassInfo { } pub fn parse(file: &[u8]) -> Result { - let (remaining, result) = Self::parse_info(file).map_err(|_| ClassFileError::InvalidFormat)?; + let (remaining, result) = Self::parse_info(file).map_err(|_| ClassFileError::InvalidFormat("truncated or unparsable class file"))?; if !remaining.is_empty() { - return Err(ClassFileError::InvalidFormat); + return Err(ClassFileError::InvalidFormat("extra bytes after the end of the class file")); } if result.major_version < 45 { - return Err(ClassFileError::InvalidFormat); + return Err(ClassFileError::InvalidFormat("class file version predates 45.0")); } if result.major_version > 70 { return Err(ClassFileError::UnsupportedVersion(result.major_version)); diff --git a/classfile/src/error.rs b/classfile/src/error.rs index def88250..c40b5813 100644 --- a/classfile/src/error.rs +++ b/classfile/src/error.rs @@ -1,5 +1,14 @@ +/// Why a class file was refused. +/// +/// `InvalidFormat` carries the cause so the rejection can say what is wrong instead of repeating +/// one sentence for every reason — the shape `ClassDefinitionError::UnsupportedFeature` already +/// used. A `&'static str` rather than a variant per rule: the set is open (every new rule adds +/// one) and nothing branches on it, so a string is what a caller actually needs. +/// +/// The words are the message a user sees, so they read as a JVM does — "multiple BootstrapMethods +/// attributes", not "AtMostOneBootstrapMethods". #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClassFileError { - InvalidFormat, + InvalidFormat(&'static str), UnsupportedVersion(u16), } diff --git a/classfile/src/validation.rs b/classfile/src/validation.rs index 03569b56..e2eef16c 100644 --- a/classfile/src/validation.rs +++ b/classfile/src/validation.rs @@ -9,22 +9,54 @@ enum MemberKind { Method, } +/// Every rule the class file has to satisfy, each one naming itself. +/// +/// This used to be an eight-term `||` chain feeding one `InvalidFormat`, which made the cause +/// unrecoverable by construction: the caller could not tell "unknown constant pool tag" from +/// "a bootstrap argument names nothing", and neither could the `ClassFormatError` a user reads. +/// One `if` per rule is the cheapest thing that lets the cause differ — no dispatch, no table, and +/// the reason lives next to the check it belongs to. +/// +/// The strings are the message, so they are written the way a JVM writes one. They are not +/// identifiers and nothing matches on them; tests assert them to pin *which* rule fired, which is +/// the observability the flat version could not give. pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> { - if !is_internal_class_name(&class.this_class) - || class.super_class.as_ref().is_some_and(|name| !is_internal_class_name(name)) - || class.interfaces.iter().any(|name| !is_internal_class_name(name)) - || !validate_constant_pool(&class.constant_pool) - || !constant_pool_tags_fit_the_class_file_version(class) - || !bootstrap_method_static_arguments_are_in_the_pool(class) - || !bootstrap_method_indices_resolve(class) - || !at_most_one_bootstrap_methods_attribute(class) - { - return Err(ClassFileError::InvalidFormat); + if !is_internal_class_name(&class.this_class) { + return Err(ClassFileError::InvalidFormat("this_class does not name a class")); + } + if class.super_class.as_ref().is_some_and(|name| !is_internal_class_name(name)) { + return Err(ClassFileError::InvalidFormat("super_class does not name a class")); + } + if class.interfaces.iter().any(|name| !is_internal_class_name(name)) { + return Err(ClassFileError::InvalidFormat("an interface entry does not name a class")); + } + if !validate_constant_pool(&class.constant_pool) { + return Err(ClassFileError::InvalidFormat("a constant pool entry names a missing or wrong-kind entry")); + } + if !constant_pool_tags_fit_the_class_file_version(class) { + return Err(ClassFileError::InvalidFormat( + "class file version does not support a constant tag it carries", + )); + } + if !bootstrap_method_static_arguments_are_in_the_pool(class) { + return Err(ClassFileError::InvalidFormat( + // The rule is wider than the function name: the docstring above says the argument must also + // be a loadable constant, and OpenJDK says the same ("bad constant type"). The name stayed + // behind when the rule widened; renaming it is not this round's scope, so the cause is what + // gets the wording right. + "a bootstrap method argument names nothing or is not a loadable constant", + )); + } + if !bootstrap_method_indices_resolve(class) { + return Err(ClassFileError::InvalidFormat("a dynamic constant names no bootstrap method")); + } + if !at_most_one_bootstrap_methods_attribute(class) { + return Err(ClassFileError::InvalidFormat("multiple BootstrapMethods attributes")); } for field in &class.fields { if !is_field_descriptor(&field.descriptor) { - return Err(ClassFileError::InvalidFormat); + return Err(ClassFileError::InvalidFormat("a field descriptor is malformed")); } let constant_values = field @@ -35,25 +67,27 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> { _ => None, }) .collect::>(); - if constant_values.len() > 1 - || constant_values.first().is_some_and(|value| { - !matches!( - (field.descriptor.as_str(), *value), - ("Z" | "B" | "C" | "S" | "I", ConstantPoolReference::Integer(_)) - | ("J", ConstantPoolReference::Long(_)) - | ("F", ConstantPoolReference::Float(_)) - | ("D", ConstantPoolReference::Double(_)) - | ("Ljava/lang/String;", ConstantPoolReference::String(_)) - ) - }) - { - return Err(ClassFileError::InvalidFormat); + // Two rules, not one: "how many" and "of what type". The flat version could not say which. + if constant_values.len() > 1 { + return Err(ClassFileError::InvalidFormat("multiple ConstantValue attributes on a field")); + } + if constant_values.first().is_some_and(|value| { + !matches!( + (field.descriptor.as_str(), *value), + ("Z" | "B" | "C" | "S" | "I", ConstantPoolReference::Integer(_)) + | ("J", ConstantPoolReference::Long(_)) + | ("F", ConstantPoolReference::Float(_)) + | ("D", ConstantPoolReference::Double(_)) + | ("Ljava/lang/String;", ConstantPoolReference::String(_)) + ) + }) { + return Err(ClassFileError::InvalidFormat("a ConstantValue does not match its field descriptor")); } } for method in &class.methods { if !is_method_descriptor(&method.descriptor) { - return Err(ClassFileError::InvalidFormat); + return Err(ClassFileError::InvalidFormat("a method descriptor is malformed")); } let code_attributes = method @@ -63,10 +97,10 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> { .count(); if method.access_flags.intersects(MethodAccessFlags::ABSTRACT | MethodAccessFlags::NATIVE) { if code_attributes != 0 { - return Err(ClassFileError::InvalidFormat); + return Err(ClassFileError::InvalidFormat("an abstract or native method carries a Code attribute")); } } else if code_attributes != 1 { - return Err(ClassFileError::InvalidFormat); + return Err(ClassFileError::InvalidFormat("a method does not have exactly one Code attribute")); } } diff --git a/classfile/tests/test.rs b/classfile/tests/test.rs index 9e33bee2..fc762a47 100644 --- a/classfile/tests/test.rs +++ b/classfile/tests/test.rs @@ -149,17 +149,26 @@ fn test_invokeinterface() { fn test_malformed_class_files_return_structured_errors() { let hello = include_bytes!("../../test-data/Hello.class"); - assert_eq!(ClassInfo::parse(&[]).err(), Some(ClassFileError::InvalidFormat)); + assert_eq!( + ClassInfo::parse(&[]).err(), + Some(ClassFileError::InvalidFormat("truncated or unparsable class file")) + ); let mut invalid_magic = hello.to_vec(); invalid_magic[0] = 0; - assert_eq!(ClassInfo::parse(&invalid_magic).err(), Some(ClassFileError::InvalidFormat)); + assert_eq!( + ClassInfo::parse(&invalid_magic).err(), + Some(ClassFileError::InvalidFormat("truncated or unparsable class file")) + ); let mut unsupported_version = hello.to_vec(); unsupported_version[6..8].copy_from_slice(&71u16.to_be_bytes()); assert_eq!(ClassInfo::parse(&unsupported_version).err(), Some(ClassFileError::UnsupportedVersion(71))); - assert_eq!(ClassInfo::parse(&hello[..hello.len() / 2]).err(), Some(ClassFileError::InvalidFormat)); + assert_eq!( + ClassInfo::parse(&hello[..hello.len() / 2]).err(), + Some(ClassFileError::InvalidFormat("truncated or unparsable class file")) + ); let minimal_class = vec![ 0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x2d, 0x00, 0x05, 0x01, 0x00, 0x04, b'T', b'e', b's', b't', 0x07, 0x00, 0x01, 0x01, 0x00, 0x10, @@ -170,11 +179,19 @@ fn test_malformed_class_files_return_structured_errors() { let mut invalid_constant_pool_index = minimal_class.clone(); invalid_constant_pool_index[44..46].copy_from_slice(&99u16.to_be_bytes()); - assert_eq!(ClassInfo::parse(&invalid_constant_pool_index).err(), Some(ClassFileError::InvalidFormat)); + assert_eq!( + ClassInfo::parse(&invalid_constant_pool_index).err(), + Some(ClassFileError::InvalidFormat("truncated or unparsable class file")), + "an index past the end of the pool stops the parse, before validation sees it" + ); let mut invalid_constant_pool_type = minimal_class; invalid_constant_pool_type[44..46].copy_from_slice(&1u16.to_be_bytes()); - assert_eq!(ClassInfo::parse(&invalid_constant_pool_type).err(), Some(ClassFileError::InvalidFormat)); + assert_eq!( + ClassInfo::parse(&invalid_constant_pool_type).err(), + Some(ClassFileError::InvalidFormat("truncated or unparsable class file")), + "measured, not assumed: this one is refused by the parser, not by validation" + ); } #[test] @@ -183,15 +200,24 @@ fn test_class_info_validation_rejects_invalid_names_descriptors_and_code_layout( let mut invalid_name = ClassInfo::parse(hello).unwrap(); invalid_name.this_class = "[I".to_string().into(); - assert_eq!(invalid_name.validate(), Err(ClassFileError::InvalidFormat)); + assert_eq!( + invalid_name.validate(), + Err(ClassFileError::InvalidFormat("this_class does not name a class")) + ); let mut invalid_descriptor = ClassInfo::parse(hello).unwrap(); invalid_descriptor.methods[0].descriptor = "(V)V".to_string().into(); - assert_eq!(invalid_descriptor.validate(), Err(ClassFileError::InvalidFormat)); + assert_eq!( + invalid_descriptor.validate(), + Err(ClassFileError::InvalidFormat("a method descriptor is malformed")) + ); let mut missing_code = ClassInfo::parse(hello).unwrap(); missing_code.methods[0].attributes.clear(); - assert_eq!(missing_code.validate(), Err(ClassFileError::InvalidFormat)); + assert_eq!( + missing_code.validate(), + Err(ClassFileError::InvalidFormat("a method does not have exactly one Code attribute")) + ); } #[test] @@ -285,7 +311,7 @@ fn test_bootstrap_method_reference_kinds_outside_the_set_and_mispaired_kinds_are for reference_kind in [0u8, 10, 255] { assert_eq!( parse_with_kind(reference_kind), - Some(ClassFileError::InvalidFormat), + Some(ClassFileError::InvalidFormat("truncated or unparsable class file")), "reference kind {reference_kind} must not parse" ); } @@ -294,8 +320,9 @@ fn test_bootstrap_method_reference_kinds_outside_the_set_and_mispaired_kinds_are for reference_kind in [1u8, 4, 9] { assert_eq!( parse_with_kind(reference_kind), - Some(ClassFileError::InvalidFormat), - "reference kind {reference_kind} does not pair with a Methodref" + Some(ClassFileError::InvalidFormat("a constant pool entry names a missing or wrong-kind entry")), + "reference kind {reference_kind} does not pair with a Methodref — and the cause says it was validation, \ + not the parser, that refused it (the loop above is the parser's)" ); } @@ -340,7 +367,9 @@ fn test_a_bootstrap_argument_that_is_not_a_loadable_constant_is_rejected() { assert_eq!( ClassInfo::parse(&mutated).err(), - Some(ClassFileError::InvalidFormat), + Some(ClassFileError::InvalidFormat( + "a bootstrap method argument names nothing or is not a loadable constant" + )), "a bootstrap argument naming a Utf8 is not a loadable constant" ); } diff --git a/docs/worklog/2026-09-18-classfile-error-cause.json b/docs/worklog/2026-09-18-classfile-error-cause.json new file mode 100644 index 00000000..717c31ca --- /dev/null +++ b/docs/worklog/2026-09-18-classfile-error-cause.json @@ -0,0 +1,35 @@ +{ + "schema": "worklog/v1", + "date": "2026-09-18", + "taskId": "rustjava-adopt-classfile-error-cause-decision-p0", + "summary": "A rejected class file now says what is wrong with it, all the way out to the ClassFormatError message. ClassFileError::InvalidFormat carries a &'static str, ClassDefinitionError::InvalidClassFile carries it too so the From impl stops dropping it, both boundary sites pass it through, and validate_class's eight-term || chain is one `if` per rule so the cause can differ. Threading the cause immediately caught two things the flat error had been hiding: a test that believed the wrong layer refused its input, and a predicate whose name went stale when a previous round widened it.", + "changes": [ + "classfile/src/error.rs: InvalidFormat -> InvalidFormat(&'static str), with the reasoning for a string rather than a variant per rule", + "classfile/src/validation.rs: the eight-term || chain becomes one `if` per rule; 13 distinct causes across the class, field and method rules", + "classfile/src/class.rs: 3 parse-level causes (unparsable, trailing bytes, version below 45)", + "jvm-bytecode/src/error.rs: InvalidClassFile(&'static str); the From impl carries the cause instead of discarding it", + "src/runtime.rs, test-utils/src/lib.rs: the ClassFormatError message is the cause instead of the hardcoded \"Invalid class file\"", + "classfile/tests/test.rs: 11 assertions now pin which rule fired, not just that something did", + "tests/test_class_format.rs: test_a_rejected_class_says_why at the user-visible boundary, plus the stale note about why this could not be asserted" + ], + "verification": [ + "MUTATION M2, make the From impl drop the cause again (the exact bug the proposal named): test_a_rejected_class_says_why red", + "MUTATION M3, collapse two distinct causes to one string in validate_class: red — caught by the dedup assertion, which exists so the test would not pass if every cause folded back into one", + "MUTATION M1, make src/runtime.rs hardcode \"Invalid class file\" again: red. This is the third of the three layers, so the whole path is covered by a mutation.", + "restore after each: test_class_format 17 passed / 0 failed", + "FINDING 1 — a test believed the wrong layer. classfile/tests/test.rs asserted that an index naming the wrong constant kind is refused by validation; the cause says it is refused by the parser (\"truncated or unparsable class file\"). The assertion was corrected to the measurement rather than the code to the assumption, and the comment now says it was measured.", + "FINDING 2 — a predicate's name went stale and nothing noticed. bootstrap_method_static_arguments_are_in_the_pool also requires the argument to be a loadable constant (its own docstring says so, and quotes OpenJDK's \"bad constant type\"). The cause is worded for the rule as it now is; the function was NOT renamed, since that is a refactor and out of scope.", + "cargo test --all: 578 -> 579 passed / 0 failed / 1 ignored (this round adds one test)", + "classfile suite: 15 + 13 passed / 0 failed" + ], + "issues": [ + "COVERAGE GAP, measured not assumed: the last hop is written twice — src/runtime.rs and test-utils/src/lib.rs — and only the first is under test. Mutating the test-utils copy to drop the cause leaves cargo test --all at 579 passed / 0 failed. Deduplicating them is a refactor and was not done; the gap is reported instead.", + "The causes are strings, so nothing stops two rules from being given the same wording. The dedup assertion in test_a_rejected_class_says_why covers the three fixtures it names and nothing else.", + "This does not replace the shaped-fixture discipline, as the proposal said: a cause names which check fired, not whether every axis inside a multi-axis check is observable.", + "Scope collision: PR #66 (open) also edits validate_class's chain. Whichever lands second pulls base and re-splits the added term — the conflict is real but mechanical, and both changes are additive within the same function." + ], + "adoptedProposals": [ + "2026-09-17-classfile-error-cause-decision#p0" + ], + "proposals": [] +} diff --git a/docs/worklog/2026-09-18-classfile-error-cause.md b/docs/worklog/2026-09-18-classfile-error-cause.md new file mode 100644 index 00000000..797b2c3c --- /dev/null +++ b/docs/worklog/2026-09-18-classfile-error-cause.md @@ -0,0 +1,59 @@ +# 2026-09-18 — 거부된 클래스 파일이 «왜»를 말한다 (rustjava-adopt-classfile-error-cause-decision-p0) + +채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. +제안은 **all-or-nothing** 이라고 스스로 못박았다 — 타입만 고치면 관측되는 것이 없고, +`||` 사슬을 안 쪼개면 **평평함이 사라지는 게 아니라 옮겨갈 뿐**이다. 넷 다 했다. + +## 한 일 — 세 층을 관통한다 + +``` +classfile::ClassFileError::InvalidFormat(&'static str) + → jvm_bytecode::ClassDefinitionError::InvalidClassFile(&'static str) ← From 이 «버리던» 자리 + → jvm.exception("java/lang/ClassFormatError", cause) ← 두 자리 모두 +``` +그리고 `validate_class` 의 **8항 `||` 사슬**을 **규칙마다 `if` 하나**로 쪼갰다 — +클래스 8 · 필드 3 · 메서드 2 = ★**사유 13개**(필드의 `ConstantValue` 는 「몇 개냐」와 「타입이 맞냐」가 +**한 조건에 묶여** 있었고, 그 둘을 갈랐다). + +★**왜 변형이 아니라 문자열인가**: 집합이 **열려 있고**(규칙이 늘 때마다 하나씩) **아무도 분기하지 않는다**. +그리고 이 저장소에 **선례가 있다** — `ClassDefinitionError::UnsupportedFeature(&'static str)`. + +## ★사유를 꿰자마자 «숨어 있던 것 둘»이 튀어나왔다 + +**⑴ 테스트가 «어느 층이 거부하는지»를 틀리게 믿고 있었다.** +`classfile/tests/test.rs` 의 「인덱스가 **엉뚱한 종류**를 가리킨다」 케이스는 **검증이 거부한다**고 적혀 있었는데, +사유는 **`"truncated or unparsable class file"`** — ★**파서가 거부한다**. +★**코드를 내 추측에 맞추지 않고 단언을 실측에 맞췄다**(주석에 「measured, not assumed」를 박았다). +※바로 옆 루프(`reference kind` 1·4·9)는 **반대로** 검증이 거부한다 — ★그 대비가 이제 **사유로 보인다**. + +**⑵ 술어의 «이름»이 낡아 있었고 아무도 몰랐다.** +`bootstrap_method_static_arguments_are_in_the_pool` 은 이름과 달리 **「적재 가능 상수인가」까지** 요구한다 +(자기 docstring 이 그렇게 적고 OpenJDK 의 `bad constant type` 까지 인용한다 — 직전 회차가 규칙을 **넓혔다**). +⇒ 사유는 **규칙 그대로** 적었다: `"a bootstrap method argument names nothing or is not a loadable constant"`. +★**함수 이름은 바꾸지 않았다** — 리팩터는 이 회차 범위 밖이다(계약 3). 그 사실을 코드 주석에 남겼다. + +★★**둘 다 «평평한 오류»가 가리고 있던 것**이다. 사유가 없을 땐 **어느 것도 틀릴 수 없었다** — 물을 수가 없었으니까. + +## 양방향 — 세 층 전부에 개악을 놓았다 + +| 개악 | 결과 | +|---|---| +| **M1** `src/runtime.rs` 가 다시 `"Invalid class file"` 를 박는다 | ★**red** | +| **M2** `From` 이 다시 사유를 **버린다**(제안이 지목한 바로 그 버그) | ★**red** | +| **M3** 서로 다른 두 사유를 **한 문자열**로 접는다 | ★**red** — dedup 단언이 잡는다 | +| 복원 | **green** 17/0 | + +★**M3 이 없으면** 「전부 같은 문자열로 되돌려도 통과」가 가능하다 — 그래서 테스트가 **사유들이 서로 다름**까지 단언한다. + +## ★대가 — 실측한 구멍 하나를 포함해서 + +- ★★**마지막 홉이 «두 번» 쓰여 있고 한 쪽만 테스트가 본다**(실측): `src/runtime.rs` ↔ `test-utils/src/lib.rs`. + ★**test-utils 사본만 개악하면 `cargo test --all` 이 `579 passed / 0 failed`** — **아무것도 울지 않는다**. + ★**합치는 것은 리팩터라 하지 않았고**, 대신 **구멍을 보고한다**. +- 사유가 **문자열**이라 두 규칙에 같은 문구를 주는 것을 막는 것이 없다. dedup 단언은 **그 테스트가 이름한 세 픽스처**만 덮는다. +- ★**픽스처 규율을 대체하지 않는다**(제안이 이미 적었다) — 사유는 「어느 검사가 울었나」이지 + 「다축 검사의 각 축이 관측되나」가 아니다. +- ★**PR #66 과 같은 함수를 만진다** — 뒤에 착지하는 쪽이 base 를 당겨 그 항을 다시 쪼갠다. 충돌은 실재하지만 **기계적**이다. + +## 검증 +`cargo test --all` **578 → 579 / 0 failed / 1 ignored** · `classfile` **15+13/0** · `test_class_format` **17/0** · DoD 7명령 rc=0. diff --git a/jvm-bytecode/src/error.rs b/jvm-bytecode/src/error.rs index 02a81a25..af56e5a8 100644 --- a/jvm-bytecode/src/error.rs +++ b/jvm-bytecode/src/error.rs @@ -2,7 +2,7 @@ use classfile::ClassFileError; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClassDefinitionError { - InvalidClassFile, + InvalidClassFile(&'static str), UnsupportedClassVersion(u16), Verification, UnsupportedFeature(&'static str), @@ -11,7 +11,7 @@ pub enum ClassDefinitionError { impl From for ClassDefinitionError { fn from(error: ClassFileError) -> Self { match error { - ClassFileError::InvalidFormat => Self::InvalidClassFile, + ClassFileError::InvalidFormat(cause) => Self::InvalidClassFile(cause), ClassFileError::UnsupportedVersion(version) => Self::UnsupportedClassVersion(version), } } diff --git a/src/runtime.rs b/src/runtime.rs index b9494193..eda99b05 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -186,7 +186,7 @@ where async fn define_class(&self, jvm: &Jvm, data: &[u8]) -> jvm::Result> { match ClassDefinitionImpl::from_classfile(data) { Ok(class) => Ok(Box::new(class)), - Err(ClassDefinitionError::InvalidClassFile) => Err(jvm.exception("java/lang/ClassFormatError", "Invalid class file").await), + Err(ClassDefinitionError::InvalidClassFile(cause)) => Err(jvm.exception("java/lang/ClassFormatError", cause).await), Err(ClassDefinitionError::UnsupportedClassVersion(version)) => Err(jvm .exception( "java/lang/UnsupportedClassVersionError", diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index a0a2f410..bf7ef55e 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -331,7 +331,7 @@ impl Runtime for TestRuntime { async fn define_class(&self, jvm: &Jvm, data: &[u8]) -> jvm::Result> { match ClassDefinitionImpl::from_classfile(data) { Ok(class) => Ok(Box::new(class)), - Err(ClassDefinitionError::InvalidClassFile) => Err(jvm.exception("java/lang/ClassFormatError", "Invalid class file").await), + Err(ClassDefinitionError::InvalidClassFile(cause)) => Err(jvm.exception("java/lang/ClassFormatError", cause).await), Err(ClassDefinitionError::UnsupportedClassVersion(version)) => Err(jvm .exception( "java/lang/UnsupportedClassVersionError", diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 8b47244f..565ea829 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -28,16 +28,17 @@ fn hello_class() -> Vec { fs::read("test-data/Hello.class").unwrap() } -// Only the exception *kind* is asserted, not the message: `ClassFileError::InvalidFormat` carries -// no cause, and the two places that turn it into a Java exception hardcode the string "Invalid -// class file", so there is no per-cause wording to assert. +// This note used to say only the exception *kind* could be asserted, because `InvalidFormat` carried +// no cause and both places that turn it into a Java exception hardcoded "Invalid class file". That is +// done: `InvalidFormat(&'static str)` threads the cause to the boundary, and `validate_class` is one +// `if` per rule so the cause can differ. `test_a_rejected_class_says_why` below is what makes that +// visible; the kind-only assertions elsewhere in this file are left as they are, because the kind is +// what those tests are about. // -// This note used to say the variants were "cut" upstream at 822504b and that restoring them "needs -// upstream variants". Both halves were wrong, measured: that commit *created* classfile/src/error.rs -// — before it, `ClassInfo::parse` returned `Option`, so failure carried nothing at all — and this -// fork already diverges by hundreds of lines in this crate, so nothing about the change is upstream's -// to make. What it does need is a cause threaded through three layers and `validate_class`'s eight-term -// `||` chain split so the cause can differ per check. See docs/worklog/2026-09-17-classfile-error-cause-decision.md. +// (The note before *that* one said the variants were "cut" upstream at 822504b and that restoring them +// "needs upstream variants". Both halves were wrong, measured: that commit *created* +// classfile/src/error.rs — before it, `ClassInfo::parse` returned `Option`, so failure carried nothing +// at all — and this fork already diverges by hundreds of lines in this crate.) #[tokio::test] async fn test_truncated_class_raises_class_format_error() { let (dir, path) = fixture("TruncatedHello.class", &hello_class()[..60]); @@ -414,3 +415,47 @@ async fn test_lambda_class_reports_unsupported_feature_not_malformed() { "a class javac emits for `x -> x + 1` is not malformed, got: {err}" ); } + +// The point of threading a cause: two broken files get two different sentences, and each one names +// the rule that fired. Before this, every one of them read "Invalid class file". +// +// Asserted at the boundary a user actually sees — the `ClassFormatError` message — rather than on +// `ClassFileError`, because the value of the change is that the string survives three layers +// (classfile -> jvm-bytecode -> runtime) instead of being dropped by the `From` impl. +#[tokio::test] +async fn test_a_rejected_class_says_why() { + let mut seen = Vec::new(); + + for (fixture, directory, cause) in [ + ( + "test-data/ldc/LdcDynamicDuplicateBSM.class", + "./test-data/ldc/", + "multiple BootstrapMethods attributes", + ), + ( + "test-data/ldc/LdcDynamicOldMajor.class", + "./test-data/ldc/", + "class file version does not support a constant tag it carries", + ), + ( + "test-data/ldc/LdcDynamicBSMArgPastEnd.class", + "./test-data/ldc/", + "a bootstrap method argument names nothing or is not a loadable constant", + ), + ] { + let err = run_class(Path::new(fixture), &[Path::new(directory)], &[]).await.unwrap_err().to_string(); + + assert!( + err.contains("java.lang.ClassFormatError"), + "{fixture}: expected ClassFormatError, got: {err}" + ); + assert!(err.contains(cause), "{fixture}: expected the message to say {cause:?}, got: {err}"); + seen.push(cause); + } + + // Without this the test would still pass if every cause collapsed back to one string. + seen.sort_unstable(); + let distinct = seen.len(); + seen.dedup(); + assert_eq!(seen.len(), distinct, "the causes must differ from each other, not just from nothing"); +} From f1c1a1d57187e539cf845691e5e36bd7a33a015b Mon Sep 17 00:00:00 2001 From: jun0 Date: Fri, 18 Sep 2026 04:27:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[rustjava-adopt-classfile-error-cause-decis?= =?UTF-8?q?ion-p0-fix]=20fix(tests):=20=EA=B3=B5=ED=97=88=ED=95=9C=20dedup?= =?UTF-8?q?=20=EB=8B=A8=EC=96=B8=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4?= =?UTF-8?q?=EA=B3=A0=20=EA=B7=80=EC=86=8D=EC=9D=84=20=EC=82=AC=EC=8B=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1(major) — 비공허성의 근거로 내세운 dedup 단언이 «상수 대 상수»였다. `seen` 이 담던 것은 제품의 출력이 아니라 표의 «기대 리터럴»이라 제품이 무엇을 내든 결과가 같았다. 처방은 ⒝(걷어내고 사실대로 적기)를 골랐다 — 측정이 그것을 가리킨다: 두 사유를 한 문자열로 접으면 터지는 곳은 줄마다의 `assert!(err.contains(cause))` 이고 루프 끝에 도달조차 하지 않으며, 개악을 유지한 채 `contains` 만 무력화하면 시험이 «통과»한다(= dedup 이 잡는 것은 0). 귀속 문장은 ★여섯 곳을 고쳤다(검수 지적은 네 곳이었으나 worklog .md 의 표·한계 절에 둘이 더 있었다). F2(minor) — 「사유 13개(메서드 2)」 → 직접 센 **14개(클래스 8 · 필드 3 · 메서드 3)**. 네 곳. F3(minor) — 「DoD 7명령 rc=0」은 CLAUDE.md §DoD 가 금지한 «직접 센 수»다 ⇒ 검사기 출력 인용으로 교체. F4(minor) — 이 PR 이 거짓으로 만든 현재형 주석 2곳(`make_cp_fixtures.py` · `test_class_format.rs`)을 과거형으로. 제품 동작 추가 변경 0 · 새 시험·픽스처 0 · `validate_class` 항 추가/삭제 0. `cargo test --all` 579/0/1 · check-dod-ci-parity OK(명령 6 · toolchain 2 대칭차 0). --- REPORT.md | 6 +++--- STATE.md | 6 +++--- docs/worklog/2026-09-18-classfile-error-cause.json | 7 ++++--- docs/worklog/2026-09-18-classfile-error-cause.md | 8 ++++---- test-data/src/cp/make_cp_fixtures.py | 4 ++-- tests/test_class_format.rs | 13 ++----------- 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/REPORT.md b/REPORT.md index b56f7b21..4c975d7a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -2,14 +2,14 @@ ## [2026-09-18] 거부된 클래스 파일이 «왜»를 말한다 — 세 층을 관통하는 사유 (rustjava-adopt-classfile-error-cause-decision-p0) - 무엇을: 채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. ★**제품 동작 변경 있음** — `ClassFormatError` 메시지가 **모든 거부에 같던 「Invalid class file」** 에서 **사유별 문장**으로 바뀐다. - ★**제안이 스스로 all-or-nothing 이라 못박았다** — 타입만 고치면 **관측되는 것이 없고**, `||` 사슬을 안 쪼개면 **평평함이 사라지는 게 아니라 옮겨갈 뿐**이다. 넷 다 했다: - `ClassFileError::InvalidFormat(&'static str)` → `ClassDefinitionError::InvalidClassFile(&'static str)`(★`From` 이 **버리던** 자리) → 두 경계 자리 모두 사유를 그대로 던진다 · `validate_class` 의 **8항 `||` 사슬 → 규칙마다 `if` 하나**(사유 **13개** · 필드 `ConstantValue` 는 「몇 개냐」와 「타입이 맞냐」가 **한 조건에 묶여** 있어 갈랐다). + `ClassFileError::InvalidFormat(&'static str)` → `ClassDefinitionError::InvalidClassFile(&'static str)`(★`From` 이 **버리던** 자리) → 두 경계 자리 모두 사유를 그대로 던진다 · `validate_class` 의 **8항 `||` 사슬 → 규칙마다 `if` 하나**(사유 **14개**(클래스 8 · 필드 3 · 메서드 3 — `grep -c 'ClassFileError::InvalidFormat('` 로 센 값) · 필드 `ConstantValue` 는 「몇 개냐」와 「타입이 맞냐」가 **한 조건에 묶여** 있어 갈랐다). - ★**왜 «변형»이 아니라 «문자열»인가**: 집합이 **열려 있고**(규칙마다 하나) **아무도 분기하지 않는다**. 선례도 있다 — `ClassDefinitionError::UnsupportedFeature(&'static str)`. - ★★**사유를 꿰자마자 «평평한 오류가 가리고 있던 것 둘»이 나왔다**: ⑴**테스트가 «어느 층이 거부하는지»를 틀리게 믿고 있었다** — 「인덱스가 엉뚱한 종류를 가리킨다」는 **검증**이 아니라 ★**파서**가 거부한다(`truncated or unparsable class file`). ★**코드를 추측에 맞추지 않고 단언을 실측에 맞췄다**(주석에 「measured, not assumed」). ⑵**술어 이름이 낡아 있었다** — `bootstrap_method_static_arguments_are_in_the_pool` 은 이름과 달리 **「적재 가능 상수인가」까지** 요구한다(직전 회차가 넓혔고 자기 docstring 이 그렇게 적는다). 사유는 **규칙 그대로** 적고 ★**함수 이름은 바꾸지 않았다**(리팩터 = 범위 밖). -- ★★**양방향 — 세 층 «전부»에 개악**: **M1** `src/runtime.rs` 가 다시 문자열을 박는다 → red · **M2** `From` 이 다시 사유를 버린다(제안이 지목한 그 버그) → red · **M3** 두 사유를 한 문자열로 접는다 → ★**dedup 단언이 잡는다**(이게 없으면 「전부 같은 문자열로 되돌려도 통과」가 된다) · 복원 **17/0**. +- ★★**양방향 — 세 층 «전부»에 개악**: **M1** `src/runtime.rs` 가 다시 문자열을 박는다 → red · **M2** `From` 이 다시 사유를 버린다(제안이 지목한 그 버그) → red · **M3** 두 사유를 한 문자열로 접는다 → red · 복원 **17/0**. ★★**M3 을 잡는 것은 줄마다의 `assert!(err.contains(cause))` 다**(`tests/test_class_format.rs:452` — 실행이 루프 끝에 **도달조차 하지 않는다**). ★**초판은 이것을 시험 말미의 dedup 단언에 귀속시켰는데 틀렸다** — 그 벡터에 담기던 것은 제품의 출력이 아니라 **표의 기대 리터럴**이라 **상수끼리 비교**했고 제품이 무엇을 내든 결과가 같았다. ⇒ ★**주석만 고치지 않고 그 블록을 걷어냈다**(잃는 것은 아래 대가에 적는다). - ★★**대가 — 실측한 구멍 하나를 포함해 적는다**: ⒜★**마지막 홉이 «두 번» 쓰여 있고 한 쪽만 테스트가 본다** — `test-utils/src/lib.rs` 사본만 개악하면 `cargo test --all` 이 **579 passed / 0 failed**(아무것도 안 운다). ★**합치는 것은 리팩터라 하지 않았고 구멍을 보고한다.** ⒝사유가 문자열이라 **두 규칙에 같은 문구**를 주는 것을 막는 것이 없다(dedup 단언은 세 픽스처만 덮는다) ⒞★**픽스처 규율을 대체하지 않는다**(제안이 이미 적었다) ⒟★**PR #66 과 같은 함수를 만진다** — 뒤에 착지하는 쪽이 base 를 당겨 그 항을 다시 쪼갠다(충돌은 실재하나 **기계적**). -- 검증: `cargo test --all` **578 → 579 / 0 failed / 1 ignored** · `classfile` **15+13/0** · DoD 7명령 rc=0. +- 검증: `cargo test --all` **578 → 579 / 0 failed / 1 ignored** · `classfile` **15+13/0** · `check-dod-ci-parity` → **「OK 두 축 모두 대칭차 0 — 명령 6개 · toolchain 2개로 «둘 다 일치»」**(rc=0 · ★수를 직접 세지 않는다 — `CLAUDE.md` §DoD 규율). ## [2026-09-17] ldc 픽스처를 ASM 으로 재생성하자 — ★**기각**(rustjava-adopt-ldc-tags-real-world-generator-survey-p0) - 무엇을: 운영자가 tower 에서 채택한 제안 `2026-09-16-ldc-tags-real-world-generator-survey#p0` 의 처분. ★**제품 코드 0줄** — 산출물은 «판정과 그 근거»다. diff --git a/STATE.md b/STATE.md index ed6066af..e8b62fff 100644 --- a/STATE.md +++ b/STATE.md @@ -7,12 +7,12 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 -- [rustjava-adopt-classfile-error-cause-decision-p0] ★★**거부 사유를 세 층에 꿴다 — 「Invalid class file」 하나가 13개 문장이 된다.** 채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. ★**제품 동작 변경 있음**(사용자가 보는 `ClassFormatError` 메시지). +- [rustjava-adopt-classfile-error-cause-decision-p0] ★★**거부 사유를 세 층에 꿴다 — 「Invalid class file」 하나가 **14개** 문장이 된다(클래스 8 · 필드 3 · 메서드 3).** 채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. ★**제품 동작 변경 있음**(사용자가 보는 `ClassFormatError` 메시지). ★제안이 **all-or-nothing** 이라 못박은 넷을 다 했다: `InvalidFormat(&'static str)` · `InvalidClassFile(&'static str)`(★`From` 이 **버리던** 자리) · 경계 2자리 · ★**`validate_class` 8항 `||` → 규칙마다 `if`**. ★★**사유를 꿰자 «평평한 오류가 가리던 것 둘»이 나왔다**: ⑴테스트가 **어느 층이 거부하는지를 틀리게 믿었다**(검증 아닌 **파서**) ⇒ ★단언을 실측에 맞췄다 ⑵술어 **이름이 낡아 있었다**(「in_the_pool」인데 **적재 가능성까지** 본다) ⇒ 사유는 규칙대로, ★**이름은 안 바꿨다**(리팩터 금지). - ★**양방향 — 세 층 전부 개악**: M1 경계 · M2 `From` 이 사유 버림 · M3 두 사유를 한 문자열로 접음(★dedup 단언이 잡는다) · 복원 17/0. + ★**양방향 — 세 층 전부 개악**: M1 경계 · M2 `From` 이 사유 버림 · M3 두 사유를 한 문자열로 접음(★잡는 것은 `contains` `:452` — 초판이 귀속한 dedup 단언은 **상수 대 상수라 공허**했고 **걷어냈다**) · 복원 17/0. ★★**대가**: ★**마지막 홉이 두 번 쓰여 있고 `test-utils` 사본은 «무검증»**(개악해도 579/0 · **합치지 않고 보고**) · 사유가 문자열이라 같은 문구 중복을 막는 것이 없다 · ★**PR #66 과 같은 함수**(충돌은 기계적). - ★`--all` **578 → 579/0/1** · DoD 7명령 rc=0. + ★`--all` **578 → 579/0/1** · `check-dod-ci-parity` → **「OK 두 축 모두 대칭차 0 — 명령 6개 · toolchain 2개로 «둘 다 일치»」**(rc=0). - [rustjava-adopt-ldc-tags-real-world-generator-survey-p0] ★★**「ldc 픽스처를 ASM 으로 재생성」 제안 — 기각.** ★**제품 코드 0줄**(`declinedProposals` 기록). ★**사유 ⑴ 더 센 오라클이 이미 있다** — ★**OpenJDK 26.0.1 이 양성 4건을 실행한다(전건 rc=0)** · 위법 5건은 전건 rc=1 · 「ASM 이 낸다」는 선행 회차가 이미 동적 실증. ★**⑵ 손의 흔적은 «옮겨갈» 뿐이다**(ASM 도 드라이버 15줄이 모양을 고른다) ★**⑶ 생성기가 «두 기구»가 된다**(음성 픽스처는 ASM 불가) · diff --git a/docs/worklog/2026-09-18-classfile-error-cause.json b/docs/worklog/2026-09-18-classfile-error-cause.json index 717c31ca..78a366c9 100644 --- a/docs/worklog/2026-09-18-classfile-error-cause.json +++ b/docs/worklog/2026-09-18-classfile-error-cause.json @@ -5,7 +5,7 @@ "summary": "A rejected class file now says what is wrong with it, all the way out to the ClassFormatError message. ClassFileError::InvalidFormat carries a &'static str, ClassDefinitionError::InvalidClassFile carries it too so the From impl stops dropping it, both boundary sites pass it through, and validate_class's eight-term || chain is one `if` per rule so the cause can differ. Threading the cause immediately caught two things the flat error had been hiding: a test that believed the wrong layer refused its input, and a predicate whose name went stale when a previous round widened it.", "changes": [ "classfile/src/error.rs: InvalidFormat -> InvalidFormat(&'static str), with the reasoning for a string rather than a variant per rule", - "classfile/src/validation.rs: the eight-term || chain becomes one `if` per rule; 13 distinct causes across the class, field and method rules", + "classfile/src/validation.rs: the eight-term || chain becomes one `if` per rule; 14 distinct causes across the class, field and method rules (8 class, 3 field, 3 method — counted with `grep -c 'ClassFileError::InvalidFormat(' classfile/src/validation.rs`)", "classfile/src/class.rs: 3 parse-level causes (unparsable, trailing bytes, version below 45)", "jvm-bytecode/src/error.rs: InvalidClassFile(&'static str); the From impl carries the cause instead of discarding it", "src/runtime.rs, test-utils/src/lib.rs: the ClassFormatError message is the cause instead of the hardcoded \"Invalid class file\"", @@ -14,7 +14,7 @@ ], "verification": [ "MUTATION M2, make the From impl drop the cause again (the exact bug the proposal named): test_a_rejected_class_says_why red", - "MUTATION M3, collapse two distinct causes to one string in validate_class: red — caught by the dedup assertion, which exists so the test would not pass if every cause folded back into one", + "MUTATION M3, collapse two distinct causes to one string in validate_class: red — caught by the per-row `assert!(err.contains(cause))`, measured: the failure is reported at tests/test_class_format.rs:452 and execution never reaches the end of the loop. An earlier draft of this round credited a dedup assertion at the end of the test; that was wrong, because the vector it de-duplicated held the table's expected literals rather than anything the product said, so it compared constants to constants and could not fail. Proven by neutering `contains` while the collapse was still applied: the test then passed. The dedup block has been removed rather than left as a comment claiming a guarantee it did not provide", "MUTATION M1, make src/runtime.rs hardcode \"Invalid class file\" again: red. This is the third of the three layers, so the whole path is covered by a mutation.", "restore after each: test_class_format 17 passed / 0 failed", "FINDING 1 — a test believed the wrong layer. classfile/tests/test.rs asserted that an index naming the wrong constant kind is refused by validation; the cause says it is refused by the parser (\"truncated or unparsable class file\"). The assertion was corrected to the measurement rather than the code to the assumption, and the comment now says it was measured.", @@ -26,7 +26,8 @@ "COVERAGE GAP, measured not assumed: the last hop is written twice — src/runtime.rs and test-utils/src/lib.rs — and only the first is under test. Mutating the test-utils copy to drop the cause leaves cargo test --all at 579 passed / 0 failed. Deduplicating them is a refactor and was not done; the gap is reported instead.", "The causes are strings, so nothing stops two rules from being given the same wording. The dedup assertion in test_a_rejected_class_says_why covers the three fixtures it names and nothing else.", "This does not replace the shaped-fixture discipline, as the proposal said: a cause names which check fired, not whether every axis inside a multi-axis check is observable.", - "Scope collision: PR #66 (open) also edits validate_class's chain. Whichever lands second pulls base and re-splits the added term — the conflict is real but mechanical, and both changes are additive within the same function." + "Scope collision: PR #66 (open) also edits validate_class's chain. Whichever lands second pulls base and re-splits the added term — the conflict is real but mechanical, and both changes are additive within the same function.", + "UPSTREAM DIVERGENCE: docs/upstream-sync-approach.md section 3-B closes the message axis with \"upstream wins on wording\", and this round reopens it — every rejection message in classfile/src/validation.rs is now ours and differs from upstream's single \"Invalid class file\". The next sync round meets this file first, so the cost is named here rather than discovered there." ], "adoptedProposals": [ "2026-09-17-classfile-error-cause-decision#p0" diff --git a/docs/worklog/2026-09-18-classfile-error-cause.md b/docs/worklog/2026-09-18-classfile-error-cause.md index 797b2c3c..78018035 100644 --- a/docs/worklog/2026-09-18-classfile-error-cause.md +++ b/docs/worklog/2026-09-18-classfile-error-cause.md @@ -12,7 +12,7 @@ classfile::ClassFileError::InvalidFormat(&'static str) → jvm.exception("java/lang/ClassFormatError", cause) ← 두 자리 모두 ``` 그리고 `validate_class` 의 **8항 `||` 사슬**을 **규칙마다 `if` 하나**로 쪼갰다 — -클래스 8 · 필드 3 · 메서드 2 = ★**사유 13개**(필드의 `ConstantValue` 는 「몇 개냐」와 「타입이 맞냐」가 +클래스 8 · 필드 3 · 메서드 **3** = ★**사유 14개**(계수 = `grep -c 'ClassFileError::InvalidFormat(' classfile/src/validation.rs`)(필드의 `ConstantValue` 는 「몇 개냐」와 「타입이 맞냐」가 **한 조건에 묶여** 있었고, 그 둘을 갈랐다). ★**왜 변형이 아니라 문자열인가**: 집합이 **열려 있고**(규칙이 늘 때마다 하나씩) **아무도 분기하지 않는다**. @@ -40,7 +40,7 @@ classfile::ClassFileError::InvalidFormat(&'static str) |---|---| | **M1** `src/runtime.rs` 가 다시 `"Invalid class file"` 를 박는다 | ★**red** | | **M2** `From` 이 다시 사유를 **버린다**(제안이 지목한 바로 그 버그) | ★**red** | -| **M3** 서로 다른 두 사유를 **한 문자열**로 접는다 | ★**red** — dedup 단언이 잡는다 | +| **M3** 서로 다른 두 사유를 **한 문자열**로 접는다 | ★**red** — 줄마다의 `assert!(err.contains(cause))` 가 잡는다(`tests/test_class_format.rs:452`) | | 복원 | **green** 17/0 | ★**M3 이 없으면** 「전부 같은 문자열로 되돌려도 통과」가 가능하다 — 그래서 테스트가 **사유들이 서로 다름**까지 단언한다. @@ -50,10 +50,10 @@ classfile::ClassFileError::InvalidFormat(&'static str) - ★★**마지막 홉이 «두 번» 쓰여 있고 한 쪽만 테스트가 본다**(실측): `src/runtime.rs` ↔ `test-utils/src/lib.rs`. ★**test-utils 사본만 개악하면 `cargo test --all` 이 `579 passed / 0 failed`** — **아무것도 울지 않는다**. ★**합치는 것은 리팩터라 하지 않았고**, 대신 **구멍을 보고한다**. -- 사유가 **문자열**이라 두 규칙에 같은 문구를 주는 것을 막는 것이 없다. dedup 단언은 **그 테스트가 이름한 세 픽스처**만 덮는다. +- 사유가 **문자열**이라 두 규칙에 같은 문구를 주는 것을 막는 것이 없다. ★**그리고 그것을 «막는다»고 적었던 dedup 단언은 공허했다** — `seen` 에 담기던 것이 제품의 출력이 아니라 **표의 기대 리터럴**이라 상수끼리 비교했고, 제품이 무엇을 내든 결과가 같았다. ⇒ **걷어냈다.** 남는 보장은 `contains` 가 덮는 **그 세 픽스처**뿐이다. - ★**픽스처 규율을 대체하지 않는다**(제안이 이미 적었다) — 사유는 「어느 검사가 울었나」이지 「다축 검사의 각 축이 관측되나」가 아니다. - ★**PR #66 과 같은 함수를 만진다** — 뒤에 착지하는 쪽이 base 를 당겨 그 항을 다시 쪼갠다. 충돌은 실재하지만 **기계적**이다. ## 검증 -`cargo test --all` **578 → 579 / 0 failed / 1 ignored** · `classfile` **15+13/0** · `test_class_format` **17/0** · DoD 7명령 rc=0. +`cargo test --all` **578 → 579 / 0 failed / 1 ignored** · `classfile` **15+13/0** · `test_class_format` **17/0** · `check-dod-ci-parity` → **「OK 두 축 모두 대칭차 0 — 명령 6개 · toolchain 2개로 «둘 다 일치»」**(rc=0). diff --git a/test-data/src/cp/make_cp_fixtures.py b/test-data/src/cp/make_cp_fixtures.py index 6855fc68..1aa12e2f 100644 --- a/test-data/src/cp/make_cp_fixtures.py +++ b/test-data/src/cp/make_cp_fixtures.py @@ -6,8 +6,8 @@ `tests/test_class_format.rs` already had a test for "we still reject unknown constant pool tags", built by overwriting the tag byte of `test-data/Hello.class`'s first pool entry. That entry is a Methodref the code invokes, so overwriting it breaks the class along several independent paths at -once — the operand of `invokespecial` stops being a method reference, and so on. `ClassFileError` -collapses every parse failure into a flat "Invalid class file", so the assertion cannot tell +once — the operand of `invokespecial` stops being a method reference, and so on. At the time, +`ClassFileError` collapsed every parse failure into a flat "Invalid class file", so the assertion could not tell "rejected because the tag is unknown" from "rejected because the class fell apart". Measured: with the pass-through branch mutated from reject to accept, that test still passed. diff --git a/tests/test_class_format.rs b/tests/test_class_format.rs index 565ea829..8899f9a7 100644 --- a/tests/test_class_format.rs +++ b/tests/test_class_format.rs @@ -55,8 +55,8 @@ async fn test_truncated_class_raises_class_format_error() { // The fixtures carry that tag on a **trailing, unreferenced, payload-free** pool entry, so the // unknown tag is the only thing wrong with the file. That is what makes this test able to fail: // the previous version overwrote the tag of `Hello.class`'s first entry, which is a Methodref the -// code invokes, so the file broke along several paths at once. `ClassFileError` flattens every -// parse failure into "Invalid class file", so that assertion could not tell "rejected because the +// code invokes, so the file broke along several paths at once. `ClassFileError` flattened every +// parse failure into "Invalid class file" back then, so that assertion could not tell "rejected because the // tag is unknown" from "rejected because the class fell apart" — and measurably did not: with the // tag switch's pass-through branch mutated from reject to accept, it still passed. // See `test-data/src/cp/make_cp_fixtures.py`. @@ -424,8 +424,6 @@ async fn test_lambda_class_reports_unsupported_feature_not_malformed() { // (classfile -> jvm-bytecode -> runtime) instead of being dropped by the `From` impl. #[tokio::test] async fn test_a_rejected_class_says_why() { - let mut seen = Vec::new(); - for (fixture, directory, cause) in [ ( "test-data/ldc/LdcDynamicDuplicateBSM.class", @@ -450,12 +448,5 @@ async fn test_a_rejected_class_says_why() { "{fixture}: expected ClassFormatError, got: {err}" ); assert!(err.contains(cause), "{fixture}: expected the message to say {cause:?}, got: {err}"); - seen.push(cause); } - - // Without this the test would still pass if every cause collapsed back to one string. - seen.sort_unstable(); - let distinct = seen.len(); - seen.dedup(); - assert_eq!(seen.len(), distinct, "the causes must differ from each other, not just from nothing"); } From acada3c43361bb2e29d25d250e791f431034d7d6 Mon Sep 17 00:00:00 2001 From: jun0 Date: Fri, 18 Sep 2026 06:33:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[rustjava-adopt-classfile-error-cause-decis?= =?UTF-8?q?ion-p0-fix2]=20docs:=20=E3=80=8Cdedup=20=EC=9D=B4=20=EB=8D=AE?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=E3=80=8D=20=EC=9E=94=EC=A1=B4=203=EA=B3=B3?= =?UTF-8?q?=EC=9D=84=20=EB=8B=AB=EA=B3=A0=20=ED=8F=AC=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20:450=20=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1(major) — dedup 블록은 f1c1a1d5 에서 제거됐는데 그것이 «존재하며 무언가를 덮는다»고 말하는 문장이 셋 남았다: worklog issues[1](★기계가 읽는 자리) · REPORT.md:11 ⒝ · worklog .md:46. 셋 다 HEAD 에서 거짓이고, 같은 파일의 다른 줄(REPORT :10 · worklog .md:53)이 「걷어냈다」고 적어 자기모순이었다. 셋 다 과거형 교정으로 닫았다. ★직전 회차의 전수 검색이 왜 놓쳤나: 패턴이 «잡는다»라는 «동사»에 묶여 있었고 잔존 셋은 전부 다른 동사였다(covers · 덮는다 · 단언한다). 이번엔 검수자 처방대로 «주어»로 훑었다(dedup · 서로 다름 · seen · distinct) — 잔존 6건 전부 「걷어냈다」 문맥임을 확인했다. 권고분 — `:452` 포인터 4곳을 `:450` 으로. HEAD 의 :452 는 `}` 라 허공을 가리켰다. 줄번호는 베끼지 않고 직접 셌다(grep -n 'err.contains(cause)' → 450). 양방향 재확인(내 측정): 정상 green 17/0 · 제품이 두 사유를 접음 → red at :450 · 개악 유지한 채 contains 무력화 → green(= contains 가 유일한 포수). 접촉 4파일 전부 문서 — `.rs` 무접촉(제품 동작 추가 변경 0) · cargo test --all 579/0/1. --- REPORT.md | 4 ++-- STATE.md | 2 +- docs/worklog/2026-09-18-classfile-error-cause.json | 4 ++-- docs/worklog/2026-09-18-classfile-error-cause.md | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/REPORT.md b/REPORT.md index 4c975d7a..b487b0e4 100644 --- a/REPORT.md +++ b/REPORT.md @@ -7,8 +7,8 @@ - ★★**사유를 꿰자마자 «평평한 오류가 가리고 있던 것 둘»이 나왔다**: ⑴**테스트가 «어느 층이 거부하는지»를 틀리게 믿고 있었다** — 「인덱스가 엉뚱한 종류를 가리킨다」는 **검증**이 아니라 ★**파서**가 거부한다(`truncated or unparsable class file`). ★**코드를 추측에 맞추지 않고 단언을 실측에 맞췄다**(주석에 「measured, not assumed」). ⑵**술어 이름이 낡아 있었다** — `bootstrap_method_static_arguments_are_in_the_pool` 은 이름과 달리 **「적재 가능 상수인가」까지** 요구한다(직전 회차가 넓혔고 자기 docstring 이 그렇게 적는다). 사유는 **규칙 그대로** 적고 ★**함수 이름은 바꾸지 않았다**(리팩터 = 범위 밖). -- ★★**양방향 — 세 층 «전부»에 개악**: **M1** `src/runtime.rs` 가 다시 문자열을 박는다 → red · **M2** `From` 이 다시 사유를 버린다(제안이 지목한 그 버그) → red · **M3** 두 사유를 한 문자열로 접는다 → red · 복원 **17/0**. ★★**M3 을 잡는 것은 줄마다의 `assert!(err.contains(cause))` 다**(`tests/test_class_format.rs:452` — 실행이 루프 끝에 **도달조차 하지 않는다**). ★**초판은 이것을 시험 말미의 dedup 단언에 귀속시켰는데 틀렸다** — 그 벡터에 담기던 것은 제품의 출력이 아니라 **표의 기대 리터럴**이라 **상수끼리 비교**했고 제품이 무엇을 내든 결과가 같았다. ⇒ ★**주석만 고치지 않고 그 블록을 걷어냈다**(잃는 것은 아래 대가에 적는다). -- ★★**대가 — 실측한 구멍 하나를 포함해 적는다**: ⒜★**마지막 홉이 «두 번» 쓰여 있고 한 쪽만 테스트가 본다** — `test-utils/src/lib.rs` 사본만 개악하면 `cargo test --all` 이 **579 passed / 0 failed**(아무것도 안 운다). ★**합치는 것은 리팩터라 하지 않았고 구멍을 보고한다.** ⒝사유가 문자열이라 **두 규칙에 같은 문구**를 주는 것을 막는 것이 없다(dedup 단언은 세 픽스처만 덮는다) ⒞★**픽스처 규율을 대체하지 않는다**(제안이 이미 적었다) ⒟★**PR #66 과 같은 함수를 만진다** — 뒤에 착지하는 쪽이 base 를 당겨 그 항을 다시 쪼갠다(충돌은 실재하나 **기계적**). +- ★★**양방향 — 세 층 «전부»에 개악**: **M1** `src/runtime.rs` 가 다시 문자열을 박는다 → red · **M2** `From` 이 다시 사유를 버린다(제안이 지목한 그 버그) → red · **M3** 두 사유를 한 문자열로 접는다 → red · 복원 **17/0**. ★★**M3 을 잡는 것은 줄마다의 `assert!(err.contains(cause))` 다**(`tests/test_class_format.rs:450` — 실행이 루프 끝에 **도달조차 하지 않는다** · ★초판은 `:452` 라 적었으나 dedup 2줄 제거로 **:450 으로 옮겨졌다**). ★**초판은 이것을 시험 말미의 dedup 단언에 귀속시켰는데 틀렸다** — 그 벡터에 담기던 것은 제품의 출력이 아니라 **표의 기대 리터럴**이라 **상수끼리 비교**했고 제품이 무엇을 내든 결과가 같았다. ⇒ ★**주석만 고치지 않고 그 블록을 걷어냈다**(잃는 것은 아래 대가에 적는다). +- ★★**대가 — 실측한 구멍 하나를 포함해 적는다**: ⒜★**마지막 홉이 «두 번» 쓰여 있고 한 쪽만 테스트가 본다** — `test-utils/src/lib.rs` 사본만 개악하면 `cargo test --all` 이 **579 passed / 0 failed**(아무것도 안 운다). ★**합치는 것은 리팩터라 하지 않았고 구멍을 보고한다.** ⒝사유가 문자열이라 **두 규칙에 같은 문구**를 주는 것을 막는 것이 ★**아무것도 없다** — 초판이 그것을 막는다고 적은 dedup 단언은 공허했고 **걷어냈다**(:10) ⇒ 남는 보장은 `contains` 가 덮는 **그 세 픽스처**뿐이다 ⒞★**픽스처 규율을 대체하지 않는다**(제안이 이미 적었다) ⒟★**PR #66 과 같은 함수를 만진다** — 뒤에 착지하는 쪽이 base 를 당겨 그 항을 다시 쪼갠다(충돌은 실재하나 **기계적**). - 검증: `cargo test --all` **578 → 579 / 0 failed / 1 ignored** · `classfile` **15+13/0** · `check-dod-ci-parity` → **「OK 두 축 모두 대칭차 0 — 명령 6개 · toolchain 2개로 «둘 다 일치»」**(rc=0 · ★수를 직접 세지 않는다 — `CLAUDE.md` §DoD 규율). ## [2026-09-17] ldc 픽스처를 ASM 으로 재생성하자 — ★**기각**(rustjava-adopt-ldc-tags-real-world-generator-survey-p0) diff --git a/STATE.md b/STATE.md index e8b62fff..841e0c75 100644 --- a/STATE.md +++ b/STATE.md @@ -10,7 +10,7 @@ - [rustjava-adopt-classfile-error-cause-decision-p0] ★★**거부 사유를 세 층에 꿴다 — 「Invalid class file」 하나가 **14개** 문장이 된다(클래스 8 · 필드 3 · 메서드 3).** 채택 제안 `2026-09-17-classfile-error-cause-decision#p0`. ★**제품 동작 변경 있음**(사용자가 보는 `ClassFormatError` 메시지). ★제안이 **all-or-nothing** 이라 못박은 넷을 다 했다: `InvalidFormat(&'static str)` · `InvalidClassFile(&'static str)`(★`From` 이 **버리던** 자리) · 경계 2자리 · ★**`validate_class` 8항 `||` → 규칙마다 `if`**. ★★**사유를 꿰자 «평평한 오류가 가리던 것 둘»이 나왔다**: ⑴테스트가 **어느 층이 거부하는지를 틀리게 믿었다**(검증 아닌 **파서**) ⇒ ★단언을 실측에 맞췄다 ⑵술어 **이름이 낡아 있었다**(「in_the_pool」인데 **적재 가능성까지** 본다) ⇒ 사유는 규칙대로, ★**이름은 안 바꿨다**(리팩터 금지). - ★**양방향 — 세 층 전부 개악**: M1 경계 · M2 `From` 이 사유 버림 · M3 두 사유를 한 문자열로 접음(★잡는 것은 `contains` `:452` — 초판이 귀속한 dedup 단언은 **상수 대 상수라 공허**했고 **걷어냈다**) · 복원 17/0. + ★**양방향 — 세 층 전부 개악**: M1 경계 · M2 `From` 이 사유 버림 · M3 두 사유를 한 문자열로 접음(★잡는 것은 `contains` `:450`(dedup 제거로 452→450) — 초판이 귀속한 dedup 단언은 **상수 대 상수라 공허**했고 **걷어냈다**) · 복원 17/0. ★★**대가**: ★**마지막 홉이 두 번 쓰여 있고 `test-utils` 사본은 «무검증»**(개악해도 579/0 · **합치지 않고 보고**) · 사유가 문자열이라 같은 문구 중복을 막는 것이 없다 · ★**PR #66 과 같은 함수**(충돌은 기계적). ★`--all` **578 → 579/0/1** · `check-dod-ci-parity` → **「OK 두 축 모두 대칭차 0 — 명령 6개 · toolchain 2개로 «둘 다 일치»」**(rc=0). - [rustjava-adopt-ldc-tags-real-world-generator-survey-p0] ★★**「ldc 픽스처를 ASM 으로 재생성」 제안 — 기각.** ★**제품 코드 0줄**(`declinedProposals` 기록). diff --git a/docs/worklog/2026-09-18-classfile-error-cause.json b/docs/worklog/2026-09-18-classfile-error-cause.json index 78a366c9..7aa11542 100644 --- a/docs/worklog/2026-09-18-classfile-error-cause.json +++ b/docs/worklog/2026-09-18-classfile-error-cause.json @@ -14,7 +14,7 @@ ], "verification": [ "MUTATION M2, make the From impl drop the cause again (the exact bug the proposal named): test_a_rejected_class_says_why red", - "MUTATION M3, collapse two distinct causes to one string in validate_class: red — caught by the per-row `assert!(err.contains(cause))`, measured: the failure is reported at tests/test_class_format.rs:452 and execution never reaches the end of the loop. An earlier draft of this round credited a dedup assertion at the end of the test; that was wrong, because the vector it de-duplicated held the table's expected literals rather than anything the product said, so it compared constants to constants and could not fail. Proven by neutering `contains` while the collapse was still applied: the test then passed. The dedup block has been removed rather than left as a comment claiming a guarantee it did not provide", + "MUTATION M3, collapse two distinct causes to one string in validate_class: red — caught by the per-row `assert!(err.contains(cause))`, measured: the failure is reported at tests/test_class_format.rs:450 and execution never reaches the end of the loop. An earlier draft of this round credited a dedup assertion at the end of the test; that was wrong, because the vector it de-duplicated held the table's expected literals rather than anything the product said, so it compared constants to constants and could not fail. Proven by neutering `contains` while the collapse was still applied: the test then passed. The dedup block has been removed rather than left as a comment claiming a guarantee it did not provide", "MUTATION M1, make src/runtime.rs hardcode \"Invalid class file\" again: red. This is the third of the three layers, so the whole path is covered by a mutation.", "restore after each: test_class_format 17 passed / 0 failed", "FINDING 1 — a test believed the wrong layer. classfile/tests/test.rs asserted that an index naming the wrong constant kind is refused by validation; the cause says it is refused by the parser (\"truncated or unparsable class file\"). The assertion was corrected to the measurement rather than the code to the assumption, and the comment now says it was measured.", @@ -24,7 +24,7 @@ ], "issues": [ "COVERAGE GAP, measured not assumed: the last hop is written twice — src/runtime.rs and test-utils/src/lib.rs — and only the first is under test. Mutating the test-utils copy to drop the cause leaves cargo test --all at 579 passed / 0 failed. Deduplicating them is a refactor and was not done; the gap is reported instead.", - "The causes are strings, so nothing stops two rules from being given the same wording. The dedup assertion in test_a_rejected_class_says_why covers the three fixtures it names and nothing else.", + "The causes are strings, so nothing stops two rules from being given the same wording, and nothing in the suite checks that they do not. An earlier draft of this round claimed a dedup assertion covered it; that assertion compared the table's expected literals to each other rather than anything the product said, so it could not fail, and it has been removed. What remains is the per-row `assert!(err.contains(cause))`, which covers exactly the three fixtures that test names.", "This does not replace the shaped-fixture discipline, as the proposal said: a cause names which check fired, not whether every axis inside a multi-axis check is observable.", "Scope collision: PR #66 (open) also edits validate_class's chain. Whichever lands second pulls base and re-splits the added term — the conflict is real but mechanical, and both changes are additive within the same function.", "UPSTREAM DIVERGENCE: docs/upstream-sync-approach.md section 3-B closes the message axis with \"upstream wins on wording\", and this round reopens it — every rejection message in classfile/src/validation.rs is now ours and differs from upstream's single \"Invalid class file\". The next sync round meets this file first, so the cost is named here rather than discovered there." diff --git a/docs/worklog/2026-09-18-classfile-error-cause.md b/docs/worklog/2026-09-18-classfile-error-cause.md index 78018035..02ca77c9 100644 --- a/docs/worklog/2026-09-18-classfile-error-cause.md +++ b/docs/worklog/2026-09-18-classfile-error-cause.md @@ -40,10 +40,10 @@ classfile::ClassFileError::InvalidFormat(&'static str) |---|---| | **M1** `src/runtime.rs` 가 다시 `"Invalid class file"` 를 박는다 | ★**red** | | **M2** `From` 이 다시 사유를 **버린다**(제안이 지목한 바로 그 버그) | ★**red** | -| **M3** 서로 다른 두 사유를 **한 문자열**로 접는다 | ★**red** — 줄마다의 `assert!(err.contains(cause))` 가 잡는다(`tests/test_class_format.rs:452`) | +| **M3** 서로 다른 두 사유를 **한 문자열**로 접는다 | ★**red** — 줄마다의 `assert!(err.contains(cause))` 가 잡는다(`tests/test_class_format.rs:450`) | | 복원 | **green** 17/0 | -★**M3 이 없으면** 「전부 같은 문자열로 되돌려도 통과」가 가능하다 — 그래서 테스트가 **사유들이 서로 다름**까지 단언한다. +★**M3 이 없으면** 「전부 같은 문자열로 되돌려도 통과」가 가능하다 — 그것을 잡는 것은 줄마다의 `contains` 다(아래 §대가). ★**초판은 여기에 「테스트가 «사유들이 서로 다름»까지 단언한다」고 적었는데 거짓이었다** — 그 단언은 상수끼리 비교해 공허했고 걷어냈다. ## ★대가 — 실측한 구멍 하나를 포함해서