Skip to content

Commit 97707f2

Browse files
authored
[rustjava-checker-output-determinism-has-no-guard] feat(scripts): 검사기 출력 순서를 잠근다 — 습관을 규칙으로 (#85) [rustjava-checker-output-determinism-has-no-guard-fix]
[rustjava-checker-output-determinism-has-no-guard] feat(scripts): 검사기 출력 순서를 잠근다 — 습관을 규칙으로
2 parents 2b2a5b1 + 5887039 commit 97707f2

7 files changed

Lines changed: 328 additions & 0 deletions

File tree

‎.github/workflows/rust.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ jobs:
8181
- uses: actions/checkout@v7
8282
- run: python3 scripts/check-dod-ci-parity.py
8383

84+
# a checker that iterates a set prints its findings in a different order on different runs, so two
85+
# rounds cannot diff their output — and every other axis stays green while it does. This reads the
86+
# checkers' AST rather than re-running them. One runner, not the matrix.
87+
script_output_order:
88+
runs-on: ubuntu-latest
89+
steps:
90+
- uses: actions/checkout@v7
91+
- run: python3 scripts/check-script-output-order.py
92+
8493
# Jvm::exception unwraps new_class(), so naming a class the loader cannot resolve panics instead
8594
# of throwing. This compares the names against the registered protos (see the script's docstring
8695
# for what it cannot see). One runner, not the matrix.

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
python3 scripts/check-dod-ci-parity.py
3434
python3 scripts/check-named-exception-classes-are-loadable.py
3535
python3 scripts/check-merge-dropped-symbols.py
36+
python3 scripts/check-script-output-order.py
3637
```
3738
★★**이 블록은 이제 «기계가 지킨다» — `scripts/check-dod-ci-parity.py`(CI job `dod_parity`)가
3839
이 코드블록과 `rust.yml` 을 «각각 파싱해» 대칭차를 낸다.** 어긋나면 그 자리에서 red 다.

‎REPORT.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88
- 검증: DoD 9명령 rc 0.
99
- ★후속 추천 **2건**: ⒜**부트스트랩 클래스의 이름 없는 unwrap 패닉**(S) ⒝**`[Ljava/lang/String;` 의 «상한이 못 끝내는» 두 번째 순환**(M). 상세 = `docs/worklog/2026-09-20-error-path-class-closure.{md,json}`.
1010

11+
## [2026-09-20] 검사기 출력 순서를 «잠갔다» — 습관을 규칙으로 (rustjava-checker-output-determinism-has-no-guard)
12+
- 무엇을: 채택 제안 `2026-09-19-merge-drops-deterministic-order#p0`. `scripts/check-script-output-order.py` 신설 — ★**`scripts/*.py` 의 어떤 `for`·컴프리헨션도 `sorted(...)` 밖에서 set 을 순회하지 않는다**를 **AST 로** 단언한다. CI 잡 `script_output_order` 1개 + DoD 10번째 줄.
13+
- 왜: 전 회차가 한 단어로 고친 비결정성을 ★**아무도 잠그지 않았다**. ★**「그물 0」을 이 트리에서 재현했다** — 제품 호출부에 비결정성을 되돌려 놓고 재니 파이썬 검사기 **4종 전건 rc 0** · `cargo fmt` **rc 0**. ★해소 여부도 먼저 쟀다: `scripts/`·`rust.yml` 최종 커밋은 **`35f34797`**(그 수정 자신) · 추적 파일의 `PYTHONHASHSEED` 는 **산문과 주석뿐**이다.
14+
- ★**왜 «정적»인가 — 제안이 제시한 두 대안을 둘 다 쓰지 않았다.** ⒜**두 `PYTHONHASHSEED` 재실행**: set 은 해시 순서가 정렬 순서와 «다를 때만» 보이므로, 사고의 경로 2개에서 2회 비교는 ★**회차당 약 절반 눈을 감는다**(느린 것이 문제가 아니다). ⒝**`assert sorted`**: 제안 자신이 적은 약점 — 「다른 곳의 두 번째 출처를 못 잡는다」. ⇒ 정적 축은 **둘 다 갖지 않는다**(재실행 0 · 새 출처 포착을 M2 로 실증).
15+
- ★**양방향 2×2**(전부 **제품 호출부** · 사본 아님): **M1** `check-merge-dropped-symbols.py:254` 의 `sorted()` 제거 → **rc 1**(파일·줄 지목) ↔ 복원 **rc 0** · **M2** ★**다른 파일의 «새» 출처** `check-dod-ci-parity.py:215` `sorted(only_ci)` → `only_ci` → **rc 1** ↔ 복원 **rc 0**. 정상 = `7 script(s): 0 unordered iteration(s)` · **0.06초**.
16+
- ★**대가**: ⒜**새 CI 잡 하나** — 제안이 「real weight」라 부른 그것이고 값을 깎지 않았다(툴체인 없는 checkout + `python3` = 기존 doc 잡 4개와 같은 형상). ⒝★**측정된 사각 1건** — 튜플 언패킹으로 받은 set 은 못 본다. 오늘 실제로 하나 있다(`ci_runs, ci_tcs = parse_ci(...)`): 거기에 정렬 없는 `for t in ci_tcs:` 를 넣으니 잠금이 ★**rc 0 으로 통과**했다. 지금 틀린 곳은 없지만 **구멍은 진짜다**. ⒞dict 는 안 본다(삽입 순서 · set 이 먹이면 set 에서 잡힌다).
17+
- ★**제안보다 넓힌 곳 하나**: `target` 은 「scripts/ (all four checkers)」인데 glob 을 **`scripts/*.py`** 로 썼다 — 한 단어 차이이고 포함된 **7개 전건이 오늘 통과**한다.
18+
- ★★**게이트² 반려 승계**(PR #85 `8a633bc8` · request-changes · 검수자가 반례 12개를 **직접 만들어** 쟀다): ⒜**R1 — 넓은 약속·좁은 검사**. `", ".join(myset)`·`print(*myset)` 이 **rc 0 으로 통과**했다(사고와 **같은 계급**인데 blind spot 에도 없었다) ⇒ `str.join` **첫 인자**와 `ast.Starred`(Load) **value** 를 검사에 더하고(순증 ~10줄) ★약속 문구를 「iteration」 → **「`for`/컴프리헨션 · `str.join` · `*`-언팩 «이 셋»」**으로 바꿨다(출력 줄도 동일). ⒝**R2 — dict 단언이 «거짓»**(`dict.fromkeys` 뒤 `for k in d` 는 통과) ⇒ 그 줄을 **지웠다**. ★**`fromkeys` 만 두 줄로 잡지 않았다** — 진짜 계급은 «컨테이너를 통한 순서 오염»이고 가족 중 하나만 잡으면 **없는 프로그램을 있는 것처럼 보이게 한다**(오늘 `fromkeys` **0건** ⇒ 문안 결함이지 live 오검 아님). ⒞**R3 — 빚 한 줄**: ★이 repo 는 **python 린터·포매터·테스트가 0**(추적 파일 중 `pyproject|setup.cfg|.pre-commit|tox.ini|ruff|flake8|requirements` **0건** · `rust.yml` 의 python 은 검사기 **5회 실행뿐, lint step 없음**) ⇒ **이 파일을 기계로 보는 것은 CI 잡 «하나»**다(+자기 글롭에 자기가 들어가 한 축으로 자신을 읽는 것). ★하네스는 **만들지 않았다 — 적었다.**
19+
★**승계 양방향**(제품 호출부 글롭 그대로): `join`·`*` 반례 **rc 1 · 줄 지목** ↔ `sorted()` 씌운 둘은 **무검출**(오탐 0) ↔ 반례 제거 시 **rc 0**. 원 M1·M2 **회귀 재확인**(각 rc 1 ↔ 복원 rc 0).
20+
- 검증: DoD 10명령 rc 0 · `dod_parity` 「명령 9개 · toolchain 2개로 둘 다 일치」.
21+
- ★후속 추천: **튜플 언패킹 반환으로 오는 set 을 따라가라**(S · 위 ⒝의 그 구멍). 상세 = `docs/worklog/2026-09-20-lock-script-output-order.{md,json}`.
22+
1123
## [2026-09-19] 오류 경로의 «또 하나»는 `java/lang/String` 이고 — ★**재귀한다**(rustjava-error-path-needs-java-lang-string-measure-first)
1224
- 무엇을: 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`(worklog json 기록). ★**제안의 조건이 「측정이 먼저」였고 그대로 했다** — 선행 회차가 String 에 대해 **아무것도 주장하지 않았고**(자기 worklog 에 「실측 아님」이라 적었다) 셋 중 무엇인지가 열려 있었다.
1325
- ★★**답 = ⒜ 재귀한다**(⒝ 깨끗한 실패도, ⒞ 이미 상주도 아니다). 하니스로 String 을 숨겨 이분법으로 경계를 찾았다: 상한 **116 생존 ↔ 117 `stack overflow, aborting`(SIGABRT · rc 134)** · ★**양쪽 2회씩 재현** · ★**상한 100000 도 abort** ⇒ 상한은 «하니스가 양보하는 지점»이지 **바닥이 아니다**.

‎STATE.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
★**양방향**: `0 recursed` ok ↔ `extend` 2줄 제거 → **5 recursed FAILED** ↔ 복원 ok. 기존 잠금 2건 **무수정 통과**.
1515
★**대가**: 로더 질문 **44→51** · 스윕 **~15초** · ★배열 5개 제외(로더가 **합성**하므로 클래스 집합이 못 빠뜨린다 — 단 `[Ljava/lang/String;` 는 상한 20에서도 오버플로 = in-process 불가).
1616
★**후속 2건**: 부트스트랩 unwrap 이 **이름을 말하지 않는다**(S) · `[Ljava/lang/String;` 의 두 번째 순환(M).
17+
- [rustjava-checker-output-determinism-has-no-guard] ★★**검사기 출력 순서를 잠갔다 — 습관을 규칙으로.** 채택 제안 `2026-09-19-merge-drops-deterministic-order#p0` · 신설 `scripts/check-script-output-order.py`(AST) + CI 잡 `script_output_order` + DoD 10번째 줄.
18+
★**불변식 한 줄**: `scripts/*.py` 의 어떤 `for`·컴프리헨션도 **`sorted(...)` 밖에서 set 을 순회하지 않는다**.
19+
★**「그물 0」 재현**: 비결정성을 되돌린 채 파이썬 검사기 **4종 rc 0** · `cargo fmt` **rc 0**. ★해소 여부 선행 확인 — `scripts/`·`rust.yml` 최종 커밋은 **`35f34797`**(그 수정 자신).
20+
★**정적을 고른 이유**: 두 `PYTHONHASHSEED` 재실행은 ★**회차당 약 절반 눈을 감고**(해시 순서 = 정렬 순서면 무증상), `assert sorted` 는 **다른 곳의 새 출처를 못 잡는다**(제안 자신의 약점 기술).
21+
★**양방향 2×2**(제품 호출부): M1 `check-merge-dropped-symbols.py:254` 제거 → rc 1 ↔ 복원 rc 0 · M2 ★**다른 파일** `check-dod-ci-parity.py:215` → rc 1 ↔ 복원 rc 0. 정상 `7 script(s): 0` · 0.06초.
22+
★**측정된 사각**: 튜플 언패킹 반환 set 은 못 본다 — `ci_runs, ci_tcs = parse_ci(...)` 에 정렬 없는 순회를 넣으니 ★**rc 0 통과**. 후속 제안 `#p0`.
23+
★★**게이트² 반려 승계(`-fix`)**: **R1** `", ".join(myset)`·`print(*myset)` 이 통과했다(사고와 **같은 계급** · 미기재) ⇒ `str.join` 첫 인자 + `Starred`(Load) 를 검사에 더하고 약속 문구를 ★**«for/컴프리헨션 · str.join · \*-언팩» 세 위치로 명시** ·
24+
**R2** 「set→dict 는 set 에서 잡힌다」가 **거짓**(`dict.fromkeys`)이라 **삭제**. ★`fromkeys` 만 반쪽으로 잡지 «않았다» — 진짜 계급은 «컨테이너 순서 오염»이고 하나만 잡으면 **없는 프로그램을 있는 것처럼 보이게 한다**(오늘 0건 ⇒ 문안 결함) ·
25+
**R3** ★**python 린터·포매터·테스트 0**(추적 파일 0건 · `rust.yml` 은 검사기 5회 실행뿐) ⇒ **이 파일을 보는 기계는 CI 잡 «하나»**임을 빚으로 적었다(하네스는 만들지 «않았다»).
26+
★승계 양방향: `join`·`*` 반례 **rc 1** ↔ `sorted()` 씌우면 **무검출**(오탐 0) ↔ 반례 제거 **rc 0** · 원 M1·M2 **회귀 재확인**.
1727
- [rustjava-error-path-needs-java-lang-string-measure-first] ★★**오류 경로의 또 하나 `java/lang/String` — ⒜ «재귀한다»로 확정하고 선재 확인을 넣었다.** 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`. ★제안의 조건이 「측정이 먼저」였고 그대로 했다.
1828
★**실측**: 상한 **116 생존 ↔ 117 SIGABRT «stack overflow, aborting»**(양쪽 2회 재현) · ★**상한 100000 도 abort** ⇒ 바닥이 없다.
1929
★**⒞ 아님을 구조로**: `bootstrap_classes` **6개에 String 없음** · `from_rust_class` 는 이름을 **`[B`(nameBytes)** 로 넣는다 ⇒ 처음 필요한 곳은 프로퍼티 루프.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"date": "2026-09-20",
3+
"taskId": "rustjava-checker-output-determinism-has-no-guard",
4+
"summary": "Adopted 2026-09-19-merge-drops-deterministic-order#p0. scripts/check-script-output-order.py refuses any `for`/comprehension in scripts/*.py that iterates a set outside sorted(). Static (AST), no re-runs, one new CI job.",
5+
"decision": "Assert the invariant statically rather than re-running each checker under two PYTHONHASHSEED values. A set is only *visibly* unordered when its hash order differs from the sorted one, so a two-run comparison is a coin flip per run -- with the two paths of the 2026-09-19 incident it agrees with itself about half the time. The AST pass does not need the bug to be observable, costs no re-run, and fires on a new set anywhere in scripts/, which is the objection the proposal itself raised against the cheap `assert sorted` variant.",
6+
"measurements": {
7+
"guard_before": "zero, re-measured on this tree: with the nondeterminism restored at the product call site, check-worklog-json / check-dod-ci-parity / check-named-exception-classes-are-loadable / check-merge-dropped-symbols all rc 0 and `cargo fmt --all -- --check` rc 0",
8+
"already_fixed_elsewhere": "no: last commit touching scripts/ or rust.yml is 35f34797, the one-word fix itself; PYTHONHASHSEED appears in no lock, only in prose and the fixed script's comment",
9+
"scope_widened_from_proposal": "proposal said `scripts/ (all four checkers)`; the glob is scripts/*.py (7 files incl. the two surveys and this script) because the wider glob costs one word and all 7 pass today",
10+
"unordered_iterations_today": 0,
11+
"ci_cost": "one new job (script_output_order), checkout + python3, no toolchain -- same shape as the other four doc/script jobs",
12+
"runtime": "0.06s over 7 files"
13+
},
14+
"verification": {
15+
"bidirectional_M1": "product call site, not a copy: removing sorted() from scripts/check-merge-dropped-symbols.py:254 -> rc 1, names that file and line; restoring -> rc 0",
16+
"bidirectional_M2": "a *new* source in a different file: `for c in sorted(only_ci)` -> `for c in only_ci` in check-dod-ci-parity.py:215 -> rc 1, names that file and line; restoring -> rc 0",
17+
"green_baseline": "rc 0, `7 script(s): 0 unordered iteration(s) that could reach output`",
18+
"blind_spot_measured": "a set bound by tuple-unpacking a call return is missed: injecting an unsorted `for t in ci_tcs:` into check-dod-ci-parity.py leaves the lock rc 0. Recorded as proposal p0, not papered over.",
19+
"dod": "all 10 DoD commands rc 0; dod_parity reports 9 commands / 2 toolchains matching on both sides"
20+
},
21+
"changes": [
22+
"scripts/check-script-output-order.py: new, ~60 lines of ast walk (set-valued names/functions to a fixpoint, then every For/comprehension iterable)",
23+
".github/workflows/rust.yml: new job script_output_order",
24+
"CLAUDE.md: the DoD block gains the 10th command (dod_parity requires both sides to move together)"
25+
],
26+
"issues": [
27+
"Set-ness is inferred syntactically, so a set arriving by tuple-unpacked call return, import or parameter is invisible to the pass. One such name exists today and is named in the docstring.",
28+
"Nothing in this repo reads python except the CI jobs that execute it: git-tracked files matching pyproject/setup.cfg/.pre-commit/tox.ini/ruff/flake8/requirements number 0, and rust.yml runs python only as `python3 scripts/<checker>.py` (5 invocations, no lint step). So this file's own correctness rests on one CI job running it, plus the fact that it is inside its own scripts/*.py glob and therefore reads itself on one axis. A python test harness was deliberately not built -- the adopted proposal scoped that as a decision, not a line -- but the debt is recorded rather than implied.",
29+
"Order laundered through a container is not followed (dict.fromkeys, list(<set>) bound to a name, bag['k']). An earlier docstring claimed the dict case was safe; a review disproved it and the claim is now removed rather than narrowed into a half-fix. dict.fromkeys appears 0 times in scripts/ today.",
30+
"Names have no scope, so `found = set()` in one function and `found = [...]` in another would make the list read go red. No collision today; recorded because a false red invites a wrong sorted()."
31+
],
32+
"adoptedProposals": [
33+
"2026-09-19-merge-drops-deterministic-order#p0"
34+
],
35+
"proposals": [
36+
{
37+
"title": "Close the one measured hole in the output-order lock: sets that arrive by tuple-unpacked call return",
38+
"plainSummary": "The new lock cannot see a set that was handed back from a function through a multiple assignment, so one kind of unordered output could still slip past it.",
39+
"userBenefit": "The lock stops having a shape it is known to miss, so a round can rely on a green run meaning what it says instead of having to remember the exception.",
40+
"why": "Measured this round, not assumed: `ci_runs, ci_tcs = parse_ci(...)` in check-dod-ci-parity.py binds a real set the pass does not recognise, and injecting an unsorted `for t in ci_tcs:` leaves the lock rc 0. Nothing is wrong there today -- every read of it goes through sorted() or a set operator -- but the hole is real and it is in the file the lock is meant to protect.",
41+
"tradeoff": "Following a value out of a call return means either inferring one return shape per function (a second, smaller fixpoint over tuple returns) or annotating the two or three call sites, and both are more analysis than the incident that started this needed. Leaving it means the docstring carries a named exception, which is the thing that rots.",
42+
"effort": "S",
43+
"target": "scripts/check-script-output-order.py"
44+
}
45+
]
46+
}

0 commit comments

Comments
 (0)