Skip to content

Commit 3dbaaaf

Browse files
jun0claude
andcommitted
[rustjava-checker-output-determinism-has-no-guard] fix(scripts): 넓은 약속을 좁게 검사하지 않는다 — join·*언팩을 더하고 거짓 단언을 지운다
게이트² request-changes(PR #85 · pin 8a633bc) 승계. 검수자가 반례 12개를 직접 만들어 쟀고, 그중 셋이 이 회차의 필수 수정이다. R1 — 넓은 약속·좁은 검사. `", ".join(myset)` 과 `print(*myset)` 이 rc 0 으로 통과했다: 2026-09-19 사고와 «같은 계급»(경로 set 이 한 줄로 찍힌다)인데 blind spot 에도 없었다. 검수자의 ⑴을 골라 `str.join` 의 첫 인자와 `ast.Starred`(Load)의 value 를 검사 대상에 더했다(순증 ~10줄). 그 둘은 set 이 `for` 없이 출력에 닿는 가장 흔한 두 철자라, 약속을 지키는 쪽이 범위를 좁히는 쪽보다 싸다. 약속 문구도 함께 고쳤다 — 「iteration」이 아니라 «for/컴프리헨션 · str.join · *-언팩 이 셋»이라고 적는다(출력 줄도 동일). R2 — docstring 의 dict 단언이 거짓이었다. `dict.fromkeys(myset)` 뒤 `for k in d:` 는 통과한다. 그 줄을 지웠다. `fromkeys` 만 두 줄로 잡지 않았다 — 진짜 계급은 «컨테이너를 통한 순서 오염»이고, 가족 중 하나만 잡으면 없는 프로그램을 있는 것처럼 보이게 한다(= R2 가 지적한 실패 그대로다). 오늘 scripts/ 의 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 잡 하나뿐이다. 하네스는 만들지 않았다 — 적었다. 그 밖(검수자 기록분)도 docstring 에 넣었다: 이름에 스코프가 없어 `found` 충돌이 틀린 red 를 낸다 · 메서드형 집합연산 · while/pop 드레인 · 죽은 줄 제거. 양방향(제품 호출부 글롭): join·* 반례 rc 1(줄 지목) ↔ sorted() 씌우면 무검출 (오탐 0) ↔ 반례 제거 rc 0. 원 M1·M2 회귀 재확인(각 rc 1 ↔ 복원 rc 0). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8a633bc commit 3dbaaaf

5 files changed

Lines changed: 100 additions & 25 deletions

File tree

‎REPORT.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
- ★**양방향 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초**.
77
- ★**대가**: ⒜**새 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 에서 잡힌다).
88
- ★**제안보다 넓힌 곳 하나**: `target` 은 「scripts/ (all four checkers)」인데 glob 을 **`scripts/*.py`** 로 썼다 — 한 단어 차이이고 포함된 **7개 전건이 오늘 통과**한다.
9+
- ★★**게이트² 반려 승계**(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 잡 «하나»**다(+자기 글롭에 자기가 들어가 한 축으로 자신을 읽는 것). ★하네스는 **만들지 않았다 — 적었다.**
10+
★**승계 양방향**(제품 호출부 글롭 그대로): `join`·`*` 반례 **rc 1 · 줄 지목** ↔ `sorted()` 씌운 둘은 **무검출**(오탐 0) ↔ 반례 제거 시 **rc 0**. 원 M1·M2 **회귀 재확인**(각 rc 1 ↔ 복원 rc 0).
911
- 검증: DoD 10명령 rc 0 · `dod_parity` 「명령 9개 · toolchain 2개로 둘 다 일치」.
1012
- ★후속 추천: **튜플 언패킹 반환으로 오는 set 을 따라가라**(S · 위 ⒝의 그 구멍). 상세 = `docs/worklog/2026-09-20-lock-script-output-order.{md,json}`.
1113

‎STATE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
★**정적을 고른 이유**: 두 `PYTHONHASHSEED` 재실행은 ★**회차당 약 절반 눈을 감고**(해시 순서 = 정렬 순서면 무증상), `assert sorted` 는 **다른 곳의 새 출처를 못 잡는다**(제안 자신의 약점 기술).
1414
★**양방향 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초.
1515
★**측정된 사각**: 튜플 언패킹 반환 set 은 못 본다 — `ci_runs, ci_tcs = parse_ci(...)` 에 정렬 없는 순회를 넣으니 ★**rc 0 통과**. 후속 제안 `#p0`.
16+
★★**게이트² 반려 승계(`-fix`)**: **R1** `", ".join(myset)`·`print(*myset)` 이 통과했다(사고와 **같은 계급** · 미기재) ⇒ `str.join` 첫 인자 + `Starred`(Load) 를 검사에 더하고 약속 문구를 ★**«for/컴프리헨션 · str.join · \*-언팩» 세 위치로 명시** ·
17+
**R2** 「set→dict 는 set 에서 잡힌다」가 **거짓**(`dict.fromkeys`)이라 **삭제**. ★`fromkeys` 만 반쪽으로 잡지 «않았다» — 진짜 계급은 «컨테이너 순서 오염»이고 하나만 잡으면 **없는 프로그램을 있는 것처럼 보이게 한다**(오늘 0건 ⇒ 문안 결함) ·
18+
**R3** ★**python 린터·포매터·테스트 0**(추적 파일 0건 · `rust.yml` 은 검사기 5회 실행뿐) ⇒ **이 파일을 보는 기계는 CI 잡 «하나»**임을 빚으로 적었다(하네스는 만들지 «않았다»).
19+
★승계 양방향: `join`·`*` 반례 **rc 1** ↔ `sorted()` 씌우면 **무검출**(오탐 0) ↔ 반례 제거 **rc 0** · 원 M1·M2 **회귀 재확인**.
1620
- [rustjava-error-path-needs-java-lang-string-measure-first] ★★**오류 경로의 또 하나 `java/lang/String` — ⒜ «재귀한다»로 확정하고 선재 확인을 넣었다.** 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`. ★제안의 조건이 「측정이 먼저」였고 그대로 했다.
1721
★**실측**: 상한 **116 생존 ↔ 117 SIGABRT «stack overflow, aborting»**(양쪽 2회 재현) · ★**상한 100000 도 abort** ⇒ 바닥이 없다.
1822
★**⒞ 아님을 구조로**: `bootstrap_classes` **6개에 String 없음** · `from_rust_class` 는 이름을 **`[B`(nameBytes)** 로 넣는다 ⇒ 처음 필요한 곳은 프로퍼티 루프.

‎docs/worklog/2026-09-20-lock-script-output-order.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
"CLAUDE.md: the DoD block gains the 10th command (dod_parity requires both sides to move together)"
2525
],
2626
"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."
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()."
2831
],
2932
"adoptedProposals": [
3033
"2026-09-19-merge-drops-deterministic-order#p0"

‎docs/worklog/2026-09-20-lock-script-output-order.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,37 @@
4545
- **dict 는 안 본다**: 삽입 순서를 지키므로 결정성은 그것을 만든 쪽에 달렸고, set 이 dict 를 먹이면
4646
set 에서 잡힌다.
4747

48+
## ★게이트² 반려 승계(PR #85 `8a633bc8` · request-changes) — 세 가지를 고쳤다
49+
50+
- **R1 — 넓은 약속, 좁은 검사**: `", ".join(myset)` 과 `print(*myset)` 이 **rc 0 으로 통과**했다(검수자 probe `p09`).
51+
★2026-09-19 사고와 **같은 계급**(경로 set 이 한 줄로 찍힌다)인데 blind spot 에도 없었다.
52+
⇒ 검수자의 ⑴을 골랐다 — `str.join` 의 **첫 인자**와 `ast.Starred`(Load)의 **value** 를 검사 대상에 더했다(순증 ~10줄).
53+
★**⑵(범위 축소)가 아니라 ⑴을 고른 이유**: 그 둘은 파이썬에서 set 이 `for` 없이 출력에 닿는 **가장 흔한 두 철자**이고,
54+
더하는 값이 열 줄이라 **약속을 지키는 쪽이 더 싸다**. 약속 문구도 함께 고쳤다 — 이제 「iteration」이 아니라
55+
★**「`for`/컴프리헨션 · `str.join` · `*`-언팩 **이 셋**」**이라고 적는다(출력 줄도 같은 문면).
56+
- **R2 — docstring 의 dict 단언이 «거짓»이었다**: 「a set feeding a dict is caught at the set」 →
57+
`d = dict.fromkeys(myset)` 뒤 `for k in d:` 는 **통과한다**(probe `p02`). ★그 줄을 **지웠다**.
58+
★**`fromkeys` 만 두 줄로 잡지 «않았다»** — 진짜 계급은 «컨테이너를 통한 순서 오염»(`list(myset)` 을 이름에 묶기,
59+
`bag["k"]`)이고, 그 가족 중 하나만 잡으면 ★**없는 프로그램을 있는 것처럼 보이게 한다.** 그것이 R2 가 지적한 실패 그대로다.
60+
※오늘 `scripts/` 의 `dict.fromkeys` **0건** ⇒ **문안 결함이지 live 오검이 아니다.**
61+
- **R3 — 빚 한 줄**: ★**이 repo 는 python 린터·포매터·테스트가 «0»이다**(git 추적 파일 중
62+
`pyproject|setup.cfg|.pre-commit|tox.ini|ruff|flake8|requirements` **0건** · `rust.yml` 의 python 은
63+
`python3 scripts/<검사기>.py` **5회 실행뿐, lint step 없음**). ⇒ ★**이 파일을 기계로 보는 것은 CI 잡 «하나»**이고,
64+
그 밖에는 자기 자신이 `scripts/*.py` 글롭에 들어가 **한 축으로 스스로를 읽는 것**이 전부다. 하네스는 **만들지 않았다**
65+
(제안 자신이 「하네스는 «결정»이지 «한 줄»이 아니다」로 규모를 적었다) — **적었다.**
66+
- **그 밖(검수자 기록분)도 docstring 에 넣었다**: 이름에 **스코프가 없다**(`found` 충돌 시 리스트 순회가 **틀린 red**) ·
67+
메서드형 집합연산(`a.difference(b)`)은 못 본다 · `while s: s.pop()` 드레인은 못 본다 · `growing = False` **죽은 줄** 제거.
68+
69+
### 승계 회차 양방향 — ★**제품 호출부(`scripts/` 글롭) 그대로**
70+
| 반례 | 고침 전(검수자 실측) | 고침 후(내 실측) |
71+
|---|---|---|
72+
| `", ".join(myset)` | rc 0 **통과** | ★**rc 1 · 줄 지목** |
73+
| `print(*myset)` | rc 0 **통과** | ★**rc 1 · 줄 지목** |
74+
| `", ".join(sorted(myset))` · `print(*sorted(myset))` | — | ★**무검출**(오탐 0) |
75+
| `dict.fromkeys(myset)` → `for k in d` | rc 0 통과 | **여전히 통과**(★blind spot 으로 «적었다» · 숨기지 않았다) |
76+
| 반례 파일 제거 | — | **rc 0 · 7 script(s) · 0** |
77+
★원 M1(`check-merge-dropped-symbols.py:254`)·M2(`check-dod-ci-parity.py:215`) **회귀 재확인**: 각각 **rc 1** ↔ 복원 **rc 0**.
78+
4879
## 제안 문면보다 넓힌 곳 한 군데
4980

5081
제안의 `target` 은 「scripts/ (all four checkers)」였는데 glob 을 `scripts/*.py` 로 썼다 —

‎scripts/check-script-output-order.py‎

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
"""Refuse an unordered iteration that can reach a checker's output.
2+
"""Refuse a set read in a position whose order reaches a checker's output.
33
44
Why: on 2026-09-19 `check-merge-dropped-symbols.py` iterated a set of paths, so two runs of the
55
same command printed the same six findings in two different orders — Python's per-process string
@@ -8,8 +8,14 @@
88
round, removing it again leaves `cargo fmt`, `cargo clippy`, `cargo test` and all four python
99
checkers green. The guard was zero, which is why this file exists.
1010
11-
What it asserts, in one line a reader can check: **no `for` loop or comprehension in
12-
`scripts/*.py` iterates a set unless it is inside `sorted(...)`.**
11+
What it asserts, in one line a reader can check: **in `scripts/*.py`, no set is read in one of the
12+
three positions whose order reaches output — the iterable of a `for` or a comprehension, the first
13+
argument of `str.join`, or a `*`-unpacking — unless it is inside `sorted(...)`.**
14+
15+
The last two were added after a review wrote `", ".join(myset)` and `print(*myset)` and watched both
16+
pass. That is the 2026-09-19 incident exactly, minus the loop: a set of paths printed in hash order.
17+
Three positions is not "every position", and the sentence above says so rather than promising a
18+
coverage this does not have — the list below is the rest.
1319
1420
Why static rather than re-running under two `PYTHONHASHSEED`s: an unordered set is only *visibly*
1521
unordered when the hash order happens to differ from the sorted one, so a two-run comparison is a
@@ -18,20 +24,31 @@
1824
re-run, and it fires on a *new* set appearing anywhere in these files, not only on the one line the
1925
2026-09-19 round fixed.
2026
21-
Blind spots, stated rather than implied:
22-
* Dicts are not flagged. They iterate in insertion order, so their output order is as
23-
deterministic as whatever built them — a set feeding a dict is caught at the set.
24-
* The set-ness of a value is inferred syntactically (a `set()`/`{…}`/set comprehension, a set
25-
operator, a name or a local function that carries one). A set arriving from something this
26-
cannot see — a tuple-unpacked call return, an import, a parameter — is missed, and one such
27-
name exists today: `ci_runs, ci_tcs = parse_ci(...)` in `check-dod-ci-parity.py` binds a set
28-
this pass does not know about (it is only ever read through `sorted()` or a set operator, so
29-
nothing is wrong there now, but an unsorted iteration of it would pass). It is a check for the
30-
shape that actually bit us, not a type system.
27+
Blind spots — listed rather than implied, and none of them is a claim of safety:
28+
* **Order laundered through a container is not followed.** `d = dict.fromkeys(myset)` and then
29+
`for k in d` keeps the set's order and passes; so do `y = list(myset)` then `for x in y`, and
30+
`bag["k"] = myset` then `for x in bag["k"]`. An earlier version of this file said "a set feeding
31+
a dict is caught at the set" — that was **false**, shown by a review that ran it, and a wrong
32+
blind-spot entry is worse than a missing one because a reader takes it as a guarantee. Measured
33+
on this tree: `dict.fromkeys` appears **0 times**, so this is a hole in the promise and not a
34+
live miss. Closing it properly means following order taint through containers, which is a
35+
different program from this one; doing `fromkeys` alone would buy the *look* of that program for
36+
two lines, which is the failure this paragraph is about.
37+
* **Set-ness is inferred syntactically** — a `set()`/`{…}`/set comprehension, a set *operator*, or
38+
a name or local function carrying one. So a set that arrives some other way is missed: a
39+
tuple-unpacked call return, an import, a parameter. One such name exists today,
40+
`ci_runs, ci_tcs = parse_ci(...)` in `check-dod-ci-parity.py` — read only through `sorted()` or a
41+
set operator, so nothing is wrong there now, but an unsorted read of it would pass. Method-
42+
spelled set operations (`a.difference(b)`) are not read either, only the operators (`a - b`).
43+
* **Names have no scope.** `found = set()` in one function and `found = [...]` in another make the
44+
*list* read go red. No such collision exists today, but `found` is one of the names from the
45+
original incident, so the false red is reachable — and a false red invites a wrong `sorted()`,
46+
which is a worse outcome than a miss.
47+
* **Draining is not reading.** `while s: s.pop()` takes a set in hash order and passes (0 today).
3148
* Every `scripts/*.py`, not just the CI checkers: the surveys get diffed across rounds too, and
3249
they cost nothing to include — all of them pass today.
3350
34-
Exit: 0 every iteration is ordered, 1 at least one is not, 2 the files it checks are not there.
51+
Exit: 0 nothing found, 1 at least one unordered read, 2 the files it checks are not there.
3552
"""
3653

3754
import ast
@@ -62,7 +79,6 @@ def set_values(tree):
6279
names, funcs = set(), set()
6380
growing = True
6481
while growing:
65-
growing = False
6682
before = len(names) + len(funcs)
6783
for node in ast.walk(tree):
6884
if isinstance(node, ast.Assign):
@@ -85,12 +101,29 @@ def set_values(tree):
85101
return names, funcs
86102

87103

88-
def unordered_iterations(tree, names, funcs):
89-
"""[(line, source)] for every iteration over a set that is not wrapped in sorted()."""
90-
iterables = [node.iter for node in ast.walk(tree) if isinstance(node, (ast.For, ast.AsyncFor))]
91-
iterables += [gen.iter for node in ast.walk(tree) for gen in getattr(node, "generators", [])]
104+
105+
def order_reaching_output(tree):
106+
"""Every expression whose element order can end up in a printed line.
107+
108+
Three shapes, and the docstring's promise is exactly these three: what a `for` or comprehension
109+
walks, what `str.join` is handed, and what a `*` spreads. A `Starred` in a target
110+
(`a, *rest = ...`) is a Store and is not one of them.
111+
"""
112+
for node in ast.walk(tree):
113+
if isinstance(node, (ast.For, ast.AsyncFor)):
114+
yield node.iter
115+
for generator in getattr(node, "generators", []):
116+
yield generator.iter
117+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "join" and node.args:
118+
yield node.args[0]
119+
if isinstance(node, ast.Starred) and isinstance(node.ctx, ast.Load):
120+
yield node.value
121+
122+
123+
def unordered_reads(tree, names, funcs):
124+
"""[(line, source)] for every set read in one of those positions without a sorted() over it."""
92125
found = []
93-
for iterable in iterables:
126+
for iterable in order_reaching_output(tree):
94127
pending = [iterable]
95128
while pending:
96129
node = pending.pop()
@@ -115,16 +148,18 @@ def main():
115148
for path in scripts:
116149
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
117150
names, funcs = set_values(tree)
118-
bad = unordered_iterations(tree, names, funcs)
151+
bad = unordered_reads(tree, names, funcs)
119152
for line, source in bad:
120-
print(f"✗ {path.relative_to(ROOT)}:{line}: iterates a set — two runs can print this in two orders")
153+
print(f"✗ {path.relative_to(ROOT)}:{line}: reads a set — two runs can print this in two orders")
121154
print(f" {source}")
122-
print(" wrap the iterable in sorted(), as check-merge-dropped-symbols.py does")
155+
print(" wrap it in sorted(), as check-merge-dropped-symbols.py does")
123156
total += len(bad)
124157
if not bad:
125158
print(f" ✓ {path.relative_to(ROOT)}")
126159

127-
print(f"{len(scripts)} script(s): {total} unordered iteration(s) that could reach output")
160+
# The count names the three positions it looked at rather than claiming "could reach output":
161+
# those are not the same set, and the docstring's blind spots are the difference.
162+
print(f"{len(scripts)} script(s): {total} set(s) read unordered in a for/comprehension, str.join or *unpacking")
128163
return 1 if total else 0
129164

130165

0 commit comments

Comments
 (0)