Skip to content
12 changes: 12 additions & 0 deletions REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@
- 검증: `cargo test --all` **583 passed / 0 failed / 1 ignored**(불변 — 코드 무접촉 · ★이 브랜치 base `8c7b473f` 기준이다. 같은 날 앞 회차들의 **579** 는 PR #61 착지 «전» base 의 수라 다르다) · `check-dod-ci-parity` → **「OK 두 축 모두 대칭차 0 — 명령 6개 · toolchain 2개로 «둘 다 일치»」**.
- ★후속 추천(★**worklog `.json` `proposals[]` 에 카드 2장으로 «기계 채널»에 실었다** — 초판은 `REPORT` 에만 적어 cockpit 에 **0장**이었다): ⑴**신규 fixture 의 target 규칙**을 세울 것인가(M) ⑵**되돌릴 조건에 «관측자»를 붙인다**(S · 넷 중 셋은 문서를 열어야만 발화한다 — 게이트② 실측). ★초판이 ⑴로 적은 「`NativeMethod` 제3 축 여부」는 ★**검수자가 규명해 닫혔다**(축 2 의 다른 얼굴) ⇒ 카드로 내지 않는다. 상세 = `docs/worklog/2026-09-18-root-fixture-target-decision.md`.

## [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` 하나**(사유 **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** 두 사유를 한 문자열로 접는다 → 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] 코드 2파일 합집합 — ★**그런데 ours 의 «삭제»는 의도가 아니라 선행 머지의 «조용한 롤백»이었다** (rustjava-adopt-link-stringconcatfactory-p2-fix3)
- 무엇을: 게이트③이 `code-conflict-out-of-scope` 로 세운 PR #61 의 충돌 4파일(원장 2 + 코드 2)을 합집합으로 해소. ★제품 Rust **0줄**(테스트·픽스처 생성기만).
- ★★**브리프의 전제 하나가 반증됐다** — 「ours 가 «의도적으로» 지운 54·16줄을 되살리지 마라」였는데, 두 파일의 성격이 **정반대**였다:
Expand Down
6 changes: 6 additions & 0 deletions STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
★**이유**: 제안의 이득(「숫자 하나로 예측」)이 뒤집힌다 — 52 는 「전-indy·전-nestmate」라는 뜻을 **실제로 갖고**, 펴면 그 구분이 사라지며 유일한 커버리지가 지워진다.
★**잃는 것**: 비균일 잔존(신규 target 규칙 **미수립**) · 16건은 그대로 · ★**본 것은 40 중 20** · `NativeMethod` 차이는 **미규명**.
★`--all` **583/0/1**(불변 · base `8c7b473f` — 앞 회차의 579 는 #61 착지 전 base 다) · 되돌릴 조건 4개를 결정 문서에 명시.
- [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` `: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-link-stringconcatfactory-p2-fix2] ★★**#57 의 버전 표에 25행 등재 — 「착지 순서」가 만든 부채를 갚는다(PR #61).**
★**막힌 것은 CI 도 충돌도 아니었다**: 핀 `85cf0fba` 에서 rc=0 CI_GREEN · `git merge origin/main` **코드 충돌 0** 인데
★**합친 결과**가 #57 이 세운 「미등재 픽스처는 핀을 실패시킨다」를 어겼다(미등재 **25건** 재현).
Expand Down
6 changes: 3 additions & 3 deletions classfile/src/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,12 @@ impl ClassInfo {
}

pub fn parse(file: &[u8]) -> Result<Self, ClassFileError> {
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));
Expand Down
11 changes: 10 additions & 1 deletion classfile/src/error.rs
Original file line number Diff line number Diff line change
@@ -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),
}
88 changes: 61 additions & 27 deletions classfile/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_of_each_single_class_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_of_each_single_class_attribute(class) {
return Err(ClassFileError::InvalidFormat("a single-valued class attribute appears more than once"));
}

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
Expand All @@ -35,25 +67,27 @@ pub(crate) fn validate_class(class: &ClassInfo) -> Result<(), ClassFileError> {
_ => None,
})
.collect::<alloc::vec::Vec<_>>();
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
Expand All @@ -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"));
}
}

Expand Down
Loading
Loading