From 079c2c2682a15eb3dafeff8fcddabde9de556de3 Mon Sep 17 00:00:00 2001 From: jun0 Date: Sat, 19 Sep 2026 06:17:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[2026-09-18-nonliteral-exception-call-sites?= =?UTF-8?q?-p0]=20feat(scripts):=20=EB=B9=84=EB=A6=AC=ED=84=B0=EB=9F=B4=20?= =?UTF-8?q?=EC=82=AC=EA=B0=81=EC=9D=84=20=C2=AB=EC=84=B8=EC=96=B4=EC=84=9C?= =?UTF-8?q?=20=EC=B0=8D=EB=8A=94=EB=8B=A4=C2=BB=20=E2=80=94=20=EA=B4=80?= =?UTF-8?q?=EB=AC=B8=EC=9C=BC=EB=A1=9C=20=EC=98=AC=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 채택 제안 2026-09-18-nonliteral-exception-call-sites#p0 — 「baseline 이 0 이니 관문으로 올릴까」. ★전제 «둘 다» 하루 만에 소멸했다(origin/main @ e9910a7a 재측): ⑴「baseline is 0」 → 실제 1. jvm/tests/test_exception_construction.rs:19 이고, 그 자리는 «비리터럴이어야만» 한다 — 리터럴로 쓰면 바로 이 검사기가 red 다. ⇒ 관문을 0으로 걸었으면 제안된 날 main 이 red 였고 대상은 정상 코드다. ★제안이 자기 why 에 그 비용을 예고했고 약 4시간 뒤 현실이 됐다. ⑵「crashing the whole runtime 대신」 → 더는 죽지 않는다(…-exception-throws-instead-of-unwrap 착지). 못 싣는 이름은 NoClassDefFoundError 를 낸다 ⇒ 해악이 «죽음»에서 «틀린 catch»로 내려갔다. ⇒ 관문 대신 제안의 진짜 걱정(tradeoff: 「회차 사이에 바닥이 측정되지 않는다」)만 값싸게 고친다: 매 실행에 사각을 세어 한 줄로 찍고 ★절대 실패시키지 않는다. 종료코드 의미 불변(0/1/2). 1파일 +90/−6 · 새 DoD 명령 0 · 새 CI 잡 0. 축(제품 코드 · 양방향): jvm/src/jvm.rs 에 런타임 조립 호출 주입 → 1→2 이고 파일:줄을 지목 · 원복 → 1 · rc 양쪽 0(그것이 «관문이 아니다»의 뜻이다). 술어 민감도: 8종 분리를 지우면 42(헬퍼 호출 33 + 정의 8 + 진짜 1) ⇒ 느슨한 앵커의 산물이 아니다. ★내가 만든 회귀 둘을 재서 걷어냈다: 두 번 걷기(3.3~4.3s → 10.0~13.3s) · 개행 인덱스(~2배 잔존). 최종 비용 유의차 없음(전 1.33/3.20/1.55/1.58s ↔ 후 1.73/1.88/1.44/1.54s · 구간 겹침). 결정은 검사기 docstring 에 못박았다 — 다음 회차가 관문을 다시 제안하기 전에 읽는 자리다. --- REPORT.md | 17 +++ STATE.md | 7 ++ ...eral-blind-spot-is-reported-not-gated.json | 53 ++++++++++ ...iteral-blind-spot-is-reported-not-gated.md | 100 ++++++++++++++++++ ...ck-named-exception-classes-are-loadable.py | 96 +++++++++++++++-- 5 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.json create mode 100644 docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md diff --git a/REPORT.md b/REPORT.md index ab236d6a..72964a08 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,21 @@ # REPORT +## [2026-09-19] 비리터럴 사각을 «관문»으로 올릴까 — ★**아니다. 제안의 전제 «둘 다» 하루 만에 소멸했다**(2026-09-18-nonliteral-exception-call-sites-p0) +- 무엇을: 채택 제안 `2026-09-18-nonliteral-exception-call-sites#p0`(worklog json 기록). 검사기가 사각을 **세어서 찍고 ★절대 실패시키지 않는다**. ★결정을 **검사기 docstring 에 못박았다**(다음 회차가 관문을 다시 제안하기 «전»에 읽는 자리). +- ★★**전제 재측(`origin/main` @ `e9910a7a`)**: ⑴「baseline is **0**」 → ★**1이다**(`jvm/tests/test_exception_construction.rs:19`) ⑵「**crashing the whole runtime** 대신」 → ★**더는 죽지 않는다**(`…-exception-throws-instead-of-unwrap` 착지). +- ★**그 1자리는 «옳고», «비리터럴이어야만» 한다**: 그 테스트는 못 싣는 이름을 부르는데 리터럴로 쓰면 ★**바로 이 검사기가 red** 가 된다(작성 당시 실측). ⇒ ★**관문을 0으로 걸었으면 제안된 날 main 이 red** 였고, 그 대상은 **정상 코드**다. + ★**제안이 자기 `why` 에서 이 비용을 예고했다** — 「변수로 이름을 넘기는 정당한 리팩터가 red 가 되어 논박당한다」. ★그 예고가 **약 4시간 뒤** 현실이 됐다. +- ★**막으려던 해악도 작아졌다**: 이제 못 싣는 이름은 **프로세스를 죽이지 않고** `NoClassDefFoundError` 를 낸다 ⇒ ★**「catch 가 안 걸린다」는 조용한 오동작**이지 «죽음»이 아니다(제안의 `userBenefit` 근거가 그만큼 약해졌다). +- ★**대신 «값싼 절반»을 지었다** — 제안의 진짜 걱정은 `tradeoff` 의 「회차 사이에 바닥이 측정되지 않는다」이고, 그것은 **관문 없이** 고쳐진다: 매 실행에 한 줄로 찍는다(pass·fail 무관). + `Blind spot: 1 call site(s) … ? jvm/tests/test_exception_construction.rs:19` + 기존 `✓ 43 … all 268 loadable`. +- ★**바꾼 수**: **1파일 · +90/−6** · ★**새 DoD 명령 0 · 새 CI 잡 0 · 종료코드 의미 불변**(0/1/2 그대로). +- ★**양방향 축(제품 코드)**: `jvm/src/jvm.rs` 에 런타임 조립 호출 주입 → **1 → 2** 이고 ★**`jvm/src/jvm.rs:1390` 을 이름으로 지목** · 원복 → **1**(트리 클린) · ★**rc 는 양쪽 다 0**(그것이 «관문이 아니다»의 뜻이다). + ★**술어 민감도**: 「다른 함수 8종 분리」를 지우면 **42**(헬퍼 호출 33 + 헬퍼 «정의» 8 + 진짜 1) ⇒ ★그 수가 «느슨한 앵커의 산물»이 아님을 보인다. +- ★**대가(숨기지 않는다)**: ⒜**찍힌 수는 «무시할 수 있다»** — 관문은 강제하고 한 줄은 스크롤로 지나친다(그것이 반대편의 최강 논거다) ⒝**수는 술어만큼만 정확하다**(42가 그 실수의 모습이고, 이 회차 프로브 말고는 잠그는 것이 없다) ⒞**제품/테스트를 구별하지 않는다**(오늘 전건이 테스트인데 표시가 없다). +- ★★**내가 만든 회귀 둘을 재서 걷어냈다**(눈으로 잡은 것이 아니다): ⑴**`.rs` 전수를 두 번 걷기** 3.3~4.3s → **10.0~13.3s** ⇒ 단일 패스로 병합 ⑵**파일마다 개행 인덱스를 파이썬 루프로** 만들기(남은 ~2배의 «진짜» 원인) ⇒ `text.count` 로 되돌림(전 트리 매치가 ~850이라 «매치당 세기»가 «문자당 인덱싱»보다 싸다) ⑶앵커 `[A-Za-z0-9_]*exception\(` 가 단어문자 위치마다 시도하게 만든다 ⇒ 리터럴 앵커 + 앞 글자 검사로 교체. ⇒ ★**최종 비용 유의차 없음**(전 1.33/3.20/1.55/1.58s ↔ 후 1.73/1.88/1.44/1.54s · 구간 겹침). +- ★**되돌릴 조건**: ⑴런타임 조립 이름이 **제품 코드**에 나타나거나 ⑵**아무도 모르게 수가 는다**(그 한 줄이 정확히 그것을 막는다). +- 검증: DoD 9명령 · 아래 절. +- ★후속 추천: **그 사각이 «제품인지 테스트인지»를 말할 것인가**(S). 상세 = `docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md`. + ## [2026-09-19] 일으키려던 예외를 못 만들면 «죽었다» — 그 보고를 손에 쥔 채로(rustjava-jvm-exception-throws-instead-of-unwrap) - 무엇을: 채택 제안 `2026-09-18-named-exception-classes-are-loadable#p1`(worklog json `adoptedProposals` 기록). `Jvm::exception` 의 `.unwrap()` 두 개를 **반환**으로 바꿨다. ★**시그니처 불변 · 새 enum variant 0 · 호출부 편집 0.** - ★★**급소 — 실패가 «이미» JavaError 다.** `from_rust_string`·`new_class` 는 `jvm::Result` = `Result` 를 돌려주므로 그 실패는 **그 자체가 자바 예외**다. unwrap 은 그것을 버리고 프로세스를 죽였다. ⇒ **그대로 돌려준다.** diff --git a/STATE.md b/STATE.md index 2a140749..5789e12d 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,13 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [2026-09-18-nonliteral-exception-call-sites-p0] ★★**비리터럴 사각은 «관문»이 아니라 «보고»다 — 제안의 전제 둘 다 소멸.** 채택 제안 `2026-09-18-nonliteral-exception-call-sites#p0`. + ★**전제 재측**: 「baseline 0」 → ★**1**(그 1자리는 **정상**이고 «비리터럴이어야만» 한다 — 리터럴이면 이 검사기가 red) · 「죽는다」 → ★**더는 안 죽는다**(#76 착지) ⇒ 해악이 «죽음»에서 «틀린 catch»로 내려갔다. + ★**관문을 0으로 걸었으면 제안된 날 main 이 red** 였다 — ★제안이 자기 `why` 에 그 비용을 예고했고 **4시간 뒤** 현실이 됐다. + ★**지은 것**: 매 실행에 사각을 **세어 찍는다**(never fail) · **1파일 +90/−6** · 새 DoD 명령 **0** · 새 CI 잡 **0** · 종료코드 불변. + ★**축**: 제품 코드 주입 → **1→2**(파일:줄 지목) · 원복 → 1 · rc 양쪽 0 · 술어 민감도 **42**. + ★**대가**: 찍힌 수는 무시할 수 있다 · 수는 술어만큼만 정확 · 제품/테스트 미구별(후속 카드). + ★**회귀 둘을 스스로 만들고 재서 걷어냈다**(두 번 걷기 · 개행 인덱스) ⇒ 최종 비용 **유의차 없음**. - [rustjava-jvm-exception-throws-instead-of-unwrap] ★★**일으키려던 예외를 못 만들면 죽던 것을 «보고»로 바꿨다.** 채택 제안 `2026-09-18-named-exception-classes-are-loadable#p1`. ★시그니처 불변 · variant 0 · 호출부 편집 0. ★**급소**: `from_rust_string`·`new_class` 의 실패는 **이미 `JavaError`**(= 자바 예외)다 — unwrap 이 그것을 버렸다. ⇒ 그대로 돌려준다. ★**실측**: `panicked … unwrap() on an Err value: JavaException(java/lang/NoClassDefFoundError)` — ★올바른 보고가 **패닉 메시지 안에** 실려 사라졌다. diff --git a/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.json b/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.json new file mode 100644 index 00000000..c063cc3a --- /dev/null +++ b/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.json @@ -0,0 +1,53 @@ +{ + "date": "2026-09-19", + "taskId": "2026-09-18-nonliteral-exception-call-sites-p0", + "summary": "Decided not to gate the non-literal blind spot. Both premises of the adopted proposal had expired within a day: the baseline is 1 rather than 0 (a test must pass an unloadable name through a variable, because a literal there makes this very check red), and an unloadable name no longer crashes the runtime since the exception-throws round landed - it raises NoClassDefFoundError instead of the intended exception. Built the cheap half instead: the checker now counts and prints the blind spot and never fails on it.", + "decision": "report the blind spot, do not gate it", + "measurements": { + "baseline_claimed_by_proposal": 0, + "baseline_measured_now": 1, + "nonliteral_site": "jvm/tests/test_exception_construction.rs:19", + "files_changed": 1, + "lines_added": 90, + "lines_removed": 6, + "new_dod_commands": 0, + "new_ci_jobs": 0, + "blind_spot_with_helper_split_removed": 42, + "runtime_before_seconds": [1.33, 3.20, 1.55, 1.58], + "runtime_after_seconds": [1.73, 1.88, 1.44, 1.54], + "runtime_after_first_draft_seconds": [10.03, 13.27, 11.13], + "named_classes": 43, + "literal_call_sites": 846, + "loadable_classes": 268 + }, + "verification": [ + "axis on product code: injecting a run-time-assembled self.exception(name, ...) into jvm/src/jvm.rs moves the report from 1 to 2 and names jvm/src/jvm.rs:1390; reverting returns it to 1 with a clean tree; rc stays 0 both ways, which is the decision", + "predicate sensitivity: removing the helper-name split makes the report say 42 (33 helper calls + 8 helper definitions + the 1 real site), so the number is not an artefact of a loose anchor", + "premise re-measured against origin/main @ e9910a7a: baseline 1, and the axis test for the exception-throws round passes, i.e. an unloadable name returns NoClassDefFoundError rather than aborting", + "cost: before 1.33/3.20/1.55/1.58s vs after 1.73/1.88/1.44/1.54s, overlapping ranges" + ], + "changes": [ + "scripts/check-named-exception-classes-are-loadable.py - counts and prints run-time-assembled call sites, never fails on them; decision and its two dead premises recorded in the docstring; scan of the two axes merged into one pass", + "docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.{md,json}, REPORT.md, STATE.md" + ], + "issues": [ + "A printed number can be scrolled past; a gate cannot. That is the trade this round chose and it is the strongest argument for the other answer.", + "The count is only as good as its predicate - the 42 above is what a one-line mistake looks like - and nothing tests it except this round's probes.", + "The report does not distinguish product code from tests. Every non-literal site today is a test; a product one would read identically.", + "I introduced two runtime regressions while building this (a second full walk of every .rs, then a per-character newline index) and removed both; the first draft ran 10-13s against a 1.3-3.2s baseline." + ], + "adoptedProposals": [ + "2026-09-18-nonliteral-exception-call-sites#p0" + ], + "proposals": [ + { + "title": "Say whether a run-time-assembled exception name is in product code or in a test", + "plainSummary": "The safety check now reports the places where an error class name is built while the program runs. It does not say whether those places are real product code or just test scaffolding, and only one of those is worth worrying about.", + "userBenefit": "A genuinely risky site in the runtime would stand out immediately instead of blending in with test helpers that are fine.", + "why": "This round decided against failing on the count, precisely because the only site today is a test that has to be written that way. That judgement depends entirely on the product/test split, and the report does not carry it - so the next reader has to re-derive it by opening each path. The repo already knows the split: the non-literal round measured 781 product versus 65 test call sites using nothing more than the path.", + "tradeoff": "It is a second classification to keep honest, and path-based heuristics are exactly the kind of thing that rots when a directory is renamed; an alternative is to leave the paths and trust the reader. It also risks implying that a test site is always acceptable, which is only true while it is deliberate.", + "effort": "S", + "target": "scripts/check-named-exception-classes-are-loadable.py" + } + ] +} diff --git a/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md b/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md new file mode 100644 index 00000000..948c3a4f --- /dev/null +++ b/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md @@ -0,0 +1,100 @@ +# 2026-09-19 — Should the non-literal blind spot be a gate? No. Both of the proposal's premises expired. + +Round: `2026-09-18-nonliteral-exception-call-sites-p0` +Adopted proposal: `2026-09-18-nonliteral-exception-call-sites#p0` — +*"Decide whether nonliteral exception() call sites should be a check, now that the baseline is 0."* + +## Decision + +**No gate.** `check-named-exception-classes-are-loadable.py` now **counts and prints** the blind spot +and **never fails on it**. The reasoning is recorded in the checker's own docstring, where the next +round will read it before re-proposing the gate. + +## The proposal is one day old and both of its premises are already false + +It was written by the round that measured the blind spot at 0. Re-measured against `origin/main` +@ `e9910a7a`: + +| premise, quoted from the proposal | status now | +|---|---| +| *"now that the baseline is **0**"* | **false — it is 1.** `jvm/tests/test_exception_construction.rs:19` | +| *"instead of **crashing the whole runtime**"* | **false — it no longer crashes.** | + +**The one non-literal site is correct, and it has to be non-literal.** That test asks +`Jvm::exception` for a class no loader can provide. Written as a literal, *this very check* reports +it and goes red — measured when it was first written. So it passes the name through a variable. A +gate at zero would have been **red on `main` the day it was proposed**, against a site that is right. + +The proposal predicted this in its own `why` field: *"a gate whose baseline is 0 has its own cost — +it turns a legitimate future refactor (passing a name through a variable) into a red that must be +argued down."* That cost stopped being hypothetical roughly four hours after the sentence was +written. + +**And the harm it guards is smaller than the proposal's `userBenefit` says.** Since +`rustjava-jvm-exception-throws-instead-of-unwrap` landed (`e9910a7a`), an unloadable name does not +abort the process — it returns the `NoClassDefFoundError` the loader raised. So a run-time-assembled +unloadable name now means *the caller catches the wrong class*, which is a real bug and a quiet one, +but it is not the crash the gate was argued for. + +## What was built instead + +The proposal's actual worry is in its `tradeoff`: *"not adding it means the floor stays unmeasured +between rounds."* That is fixed without the gate — the number is printed on every run, pass or fail: + +``` +Blind spot: 1 call site(s) build the class name at run time, so this check +does not see them. Not an error -- an unloadable name there raises NoClassDefFoundError +rather than the intended exception, which is a wrong catch, not a crash: + ? jvm/tests/test_exception_construction.rs:19 +✓ 43 named exception class(es) across 846 call site(s); all 268 loadable +``` + +**Changed: 1 file, +90/−6 lines (`git diff --numstat`), 0 new commands, 0 new CI jobs.** Exit codes are untouched (0/1/2 +mean exactly what they meant). + +## Axis — bidirectional, on product code + +| probe | result | +|---|---| +| inject a run-time-assembled `self.exception(name, …)` into **`jvm/src/jvm.rs`** | `Blind spot: **2**` and it names `jvm/src/jvm.rs:1390` | +| revert | `Blind spot: **1**`, tree clean | +| remove the helper-name split from the predicate | `Blind spot: **42**` — 33 helper calls + 8 helper definitions + the 1 real site | + +The third probe is the one that shows the number *means* something: `exception(` is a substring of +eight helper functions in the test trees whose first parameter is `jvm`, not a class name. Without +that split the report would be off by a factor of 42. + +**Note on the axis clause**: the acceptance asks for "revert it and get red". This change is +deliberately incapable of red — that is the decision. So the axis is the *number* moving and naming +the new site, plus `rc` staying 0 in both directions, which is what "reported, not gated" has to +mean. + +## What this costs + +- **A number that nobody is forced to act on.** A gate makes you deal with it; a printed line can be + scrolled past. That is the trade this round chose, and it is the strongest argument for the gate. +- **The count is only as good as its predicate** — the 42 above is what a one-line mistake looks + like. It is not tested by anything except the probes in this round. +- **Product versus test is not distinguished.** Today all non-literal sites are tests; the report + does not say so, and a product site would read identically. +- Runtime: **no significant change** — before 1.33 / 3.20 / 1.55 / 1.58 s, after 1.73 / 1.88 / 1.44 / + 1.54 s (overlapping). Getting there took two rewrites; see below. + +## Three rewrites before this was free + +The first two were regressions I introduced; the third is what finally paid for the feature. All +measured, none spotted by eye: + +1. **A second full walk of every `*.rs`** — the report scanned the tree again. 3.3–4.3 s → 10.0–13.3 s. + Merged into one pass shared by both axes. +2. **A per-character newline index** built in Python for every file, to make line numbers cheap. + It *was* the remaining regression (still ~2×). Replaced with `text.count("\n", 0, …)` — there are + ~850 matches in the whole tree, so counting per match beats indexing per character. +3. Then the anchor itself: `[A-Za-z0-9_]*exception\(` forced the engine to try every word-character + position. Anchoring on the literal `exception\(` and testing the preceding character instead is + the same question and restored parity. + +## What would reopen this + +- A run-time-assembled name appears in **product** code (every site today is a test), or +- the count grows without a round noticing — which is exactly what the printed line is for. diff --git a/scripts/check-named-exception-classes-are-loadable.py b/scripts/check-named-exception-classes-are-loadable.py index f2b77cdf..9bdc5b60 100755 --- a/scripts/check-named-exception-classes-are-loadable.py +++ b/scripts/check-named-exception-classes-are-loadable.py @@ -35,6 +35,23 @@ WHAT THIS DOES NOT SEE -- it is a floor, not a proof: * A name built at run time (`format!`, a `const`, a variable, a match arm returning &str) is not a literal at the call site, so it is invisible here. Only the literal spelling is checked. + THIS ONE IS NOW COUNTED AND PRINTED, AND DELIBERATELY NOT FAILED ON -- decided 2026-09-19, + `2026-09-18-nonliteral-exception-call-sites-p0`, adopting the proposal of the same name. Do not + "finish the job" by turning that count into a non-zero exit; the proposal asked for exactly that + and both of its premises had expired by the time it was picked up: + - "now that the baseline is 0" -- it is 1. `jvm/tests/test_exception_construction.rs` has to + pass an unloadable name through a variable, because a literal there makes *this* check red. + A gate at zero would have been red on main the day it was written, against a site that is + correct. The proposal predicted this exact cost in its own `why` field. + - "instead of crashing the whole runtime" -- since `rustjava-jvm-exception-throws-instead-of- + unwrap` landed, an unloadable name does not crash. It raises NoClassDefFoundError instead of + the intended exception: a `catch` that does not match, which is a wrong behaviour and not a + dead process. + So the harm is real but smaller, and the gate's own cost is now paid up front rather than + hypothetically. Printing the number keeps the floor measured between rounds -- which is what the + proposal was actually worried about -- without turning a correct test into a build failure. + What would change the answer: a run-time-assembled name appearing in *product* code (every site + today is a test), or the count growing without anyone noticing it grew. * Only `exception(` is scanned. A class named through `new_class(` or `find_class(` directly is not covered; those paths return Result to their caller rather than unwrapping, which is why the panic axis is this one. @@ -76,6 +93,18 @@ # `pub fn as_proto() -> RuntimeClassProto { … }` inside such a block. PROTO_FN = re.compile(r"pub fn ([a-z_]+)\(\)\s*->\s*RuntimeClassProto\s*\{(.*?)\n \}", re.S) NAME_FIELD = re.compile(r'name:\s*"([^"]+)"') +# Every `exception(` site, literal or not, so the blind spot can be counted rather than assumed. +# The prefix group matters: `exception(` is a substring of eight helper functions in the test trees +# (`assert_exception(`, `suppress_io_exception(`, …, 41 sites) whose first parameter is `jvm`, not a +# class name. Counting those as run-time-assembled names answers 33 where the answer is 1. +# Anchored on the literal so the regex engine can use a fast substring search: the earlier form +# `[A-Za-z0-9_]*exception\(` made it try every word-character position and doubled the check's +# runtime. Whether the site is a *bare* `exception(` is decided by looking at the preceding +# character instead, which is the same question and costs nothing. +ANY_SITE = re.compile(r'exception\(\s*') +IDENT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") +FIRST_ARG_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"') +IS_DEFINITION = re.compile(r"\bfn\s+$") def die(message): @@ -102,6 +131,40 @@ def rust_files(): yield entry +_SCAN_CACHE = None + + +def scan_call_sites(): + """Both axes in one walk: {literal name: [site, ...]} and [non-literal site, ...]. + + One pass because the two used to be two, and reading every *.rs twice tripled the check + (measured 3.3-4.3 s -> 10.0-13.3 s). Cached because `main()` asks for both. + """ + global _SCAN_CACHE + if _SCAN_CACHE is not None: + return _SCAN_CACHE + named, blind = {}, [] + for path in rust_files(): + text = read(path) + rel = path.relative_to(ROOT) + for match in ANY_SITE.finditer(text): + if match.start() and text[match.start() - 1] in IDENT_CHARS: + continue # assert_exception( and friends: a different function + if IS_DEFINITION.search(text[max(0, match.start() - 12) : match.start()]): + continue + # `count` rather than an index of newline offsets: there are ~850 matches in the whole + # tree, and building a per-character index for every file cost more than it saved + # (measured: it was the regression, not the scan). + line = text.count("\n", 0, match.start()) + 1 + literal = NAMED.match(text, match.start()) + if literal: + named.setdefault(literal.group(1), []).append(f"{rel}:{line}") + elif not FIRST_ARG_LITERAL.match(text[match.end() :]): + blind.append(f"{rel}:{line}") + _SCAN_CACHE = (named, blind) + return _SCAN_CACHE + + def named_classes(): """{class name: [file:line, ...]} for every literal exception(...) name. @@ -109,15 +172,21 @@ def named_classes(): after `exception(` and the pattern's `\\s*` has to cross that newline. Line numbers are recovered from the match offset so the report still points at a place. """ - found = {} - for path in rust_files(): - text = read(path) - for match in NAMED.finditer(text): - line = text.count("\n", 0, match.start()) + 1 - found.setdefault(match.group(1), []).append(f"{path.relative_to(ROOT)}:{line}") + found, _ = scan_call_sites() return found +def runtime_assembled_sites(): + """`Jvm::exception` call sites whose class name is *not* a literal -- this check's blind spot. + + Reported, never failed on. The decision not to gate it is recorded in the docstring above; the + number is printed so that "how big is the blind spot" stops being something a round has to go + and measure before it can answer. + """ + _, sites = scan_call_sites() + return sites + + def loadable_classes(): """Names the bootstrap loader can return, i.e. the registered protos. @@ -168,11 +237,26 @@ def loadable_classes(): return names +def report_blind_spot(): + """One line, always, whether the check passes or fails. Never changes the exit code.""" + sites = runtime_assembled_sites() + if not sites: + print("Blind spot: 0 call sites build the class name at run time -- everything below is checked.") + return + print(f"Blind spot: {len(sites)} call site(s) build the class name at run time, so this check") + print("does not see them. Not an error -- an unloadable name there raises NoClassDefFoundError") + print("rather than the intended exception, which is a wrong catch, not a crash:") + for site in sites: + print(f" ? {site}") + + def main(): named = named_classes() loadable = loadable_classes() missing = sorted(name for name in named if name not in loadable) + report_blind_spot() + if missing: print(f"{len(missing)} named exception class(es) the runtime cannot load:") for name in missing: From 8dd83180d416e9df3dcad9b33530844f734310b1 Mon Sep 17 00:00:00 2001 From: jun0 Date: Sat, 19 Sep 2026 11:01:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[2026-09-18-nonliteral-exception-call-sites?= =?UTF-8?q?-p0-fix]=20fix(scripts):=20=EC=A0=91=EB=91=90=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=C2=AB=EB=B3=B4=EA=B3=A0=20=EC=B6=95=C2=BB?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EB=91=94=EB=8B=A4=20=E2=80=94=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=85=EB=A0=A5=EC=A7=91=ED=95=A9?= =?UTF-8?q?=EC=9D=84=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 게이트② 반려 승계(필수 1건). ★검수자가 옳다: 두 스캔을 scan_call_sites() 하나로 합치면서 보고 축에 필요한 IDENT_CHARS 접두 필터가 ★게이트 축(named)에도 같이 걸렸다. 종전 named_classes() 는 NAMED.finditer 로 «무필터»였다. ⇒ raise_exception("java/lang/X", …) 처럼 앞 글자가 식별자인 호출이 ★게이트에서도 보고에서도 빠진다(continue 가 두 분기보다 앞) — 막지도 찍지도 않는 «무증상»이다. 검수자 주입을 그대로 재현했다(jvm/src/runtime/java_lang_class.rs 에 1줄): origin/main 판본 : rc=1 ✗ java/lang/TotallyUnloadableProbe ← 잡는다 이 PR 판본(수정 전): rc=0 ✓ 43 … all 268 loadable ← 못 잡는다 수정 후 : rc=1 ✗ java/lang/TotallyUnloadableProbe …:29 ← 되돌아왔다 주입 원복 후 : rc=0 · 게이트 줄이 origin/main 과 «바이트 동일»(43 / 846 / 268) 처방은 검수자가 준 형태 그대로 — 필터를 «보고 축»으로 옮기고 게이트 축은 NAMED 무필터를 유지한다. 오늘 트리에서 그 필터는 게이트 축에 아무것도 벌지 않는다: 헬퍼 8종은 첫 인자가 jvm 이라 NAMED 의 \(\s*" 가 이미 막는다. blind spot 축 불변(필터 있으면 1 · 빼면 42). 부수: worklog 「What this costs」에 축 한 줄 추가 — 「종료코드 의미 불변」은 0/1/2 의 «뜻»에 대해 참이었고, 바뀐 것은 ★«rc 를 만드는 입력집합»이며 그것을 말하는 줄이 어디에도 없었다. ★rc 불변 계약 준수: 사각 때문에 rc 가 1 이 되는 일은 없다(관문으로 올리지 않았다). ★검수자 중간항 2종(제품 한정 관문 · 래칫)은 범위 밖이라 구현하지 않았다. --- ...9-nonliteral-blind-spot-is-reported-not-gated.md | 8 ++++++++ .../check-named-exception-classes-are-loadable.py | 13 +++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md b/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md index 948c3a4f..63a53c1e 100644 --- a/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md +++ b/docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md @@ -75,6 +75,14 @@ mean. scrolled past. That is the trade this round chose, and it is the strongest argument for the gate. - **The count is only as good as its predicate** — the 42 above is what a one-line mistake looks like. It is not tested by anything except the probes in this round. +- **The gate's *input set* is now a thing this file can get wrong, and that is a cost the first + version of this round paid.** "Exit codes are untouched" was true of what 0/1/2 *mean*, and it hid + the axis that actually matters: merging the two scans put the report axis's prefix filter on the + gate axis too, so a call written `raise_exception("java/lang/X", …)` was dropped from **both** — + not gated, not reported. Measured by gate 2 and reproduced here: an injected + `raise_exception("java/lang/TotallyUnloadableProbe", …)` gave **rc 0** against that form where the + previous version gave **rc 1**. Fixed by keeping the gate axis unfiltered. The lesson is not the + bug, it is that "the exit codes are unchanged" says nothing about **what is fed into them**. - **Product versus test is not distinguished.** Today all non-literal sites are tests; the report does not say so, and a product site would read identically. - Runtime: **no significant change** — before 1.33 / 3.20 / 1.55 / 1.58 s, after 1.73 / 1.88 / 1.44 / diff --git a/scripts/check-named-exception-classes-are-loadable.py b/scripts/check-named-exception-classes-are-loadable.py index 9bdc5b60..3f422b1a 100755 --- a/scripts/check-named-exception-classes-are-loadable.py +++ b/scripts/check-named-exception-classes-are-loadable.py @@ -148,8 +148,6 @@ def scan_call_sites(): text = read(path) rel = path.relative_to(ROOT) for match in ANY_SITE.finditer(text): - if match.start() and text[match.start() - 1] in IDENT_CHARS: - continue # assert_exception( and friends: a different function if IS_DEFINITION.search(text[max(0, match.start() - 12) : match.start()]): continue # `count` rather than an index of newline offsets: there are ~850 matches in the whole @@ -158,7 +156,18 @@ def scan_call_sites(): line = text.count("\n", 0, match.start()) + 1 literal = NAMED.match(text, match.start()) if literal: + # ★ No prefix filter on this branch, and that is the point. This is the gate's input + # set, and `NAMED` already requires `("java…` right after the paren -- which the + # helper functions cannot satisfy, because their first argument is `jvm`. Filtering + # here buys nothing (measured: 43 / 846 / 268 either way) and costs coverage: a call + # written `raise_exception("java/lang/X", …)` would be dropped from the gate *and* + # from the blind-spot report, so a class the runtime cannot load would read as + # `✓ all loadable`. Measured on the form that filtered here: rc 0 against an injected + # `raise_exception("java/lang/TotallyUnloadableProbe", …)` that the previous version + # caught with rc 1. named.setdefault(literal.group(1), []).append(f"{rel}:{line}") + elif match.start() and text[match.start() - 1] in IDENT_CHARS: + continue # assert_exception( and friends: a different function, first argument `jvm` elif not FIRST_ARG_LITERAL.match(text[match.end() :]): blind.append(f"{rel}:{line}") _SCAN_CACHE = (named, blind)