From 8a633bc85b5e548dba0d66491955087fc23c7f77 Mon Sep 17 00:00:00 2001 From: jun0 Date: Sun, 20 Sep 2026 09:30:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[rustjava-checker-output-determinism-has-no?= =?UTF-8?q?-guard]=20feat(scripts):=20=EA=B2=80=EC=82=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=88=9C=EC=84=9C=EB=A5=BC=20=EC=9E=A0?= =?UTF-8?q?=EA=B7=BC=EB=8B=A4=20=E2=80=94=20=EC=8A=B5=EA=B4=80=EC=9D=84=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 채택 제안 `2026-09-19-merge-drops-deterministic-order#p0`. 불변식 한 줄: `scripts/*.py` 의 어떤 `for`·컴프리헨션도 `sorted(...)` 밖에서 set 을 순회하지 않는다. AST 로 단언한다(재실행 0). 왜 정적인가 — 제안이 제시한 두 대안을 둘 다 쓰지 않았다: * 두 PYTHONHASHSEED 재실행: set 은 해시 순서가 정렬 순서와 «다를 때만» 보이므로 사고의 경로 2개에서 2회 비교는 회차당 약 절반 눈을 감는다. * `assert sorted`: 제안 자신이 적은 약점 — 다른 곳의 두 번째 출처를 못 잡는다. 그물 0 재현: 비결정성을 되돌린 채 파이썬 검사기 4종 rc 0 · cargo fmt rc 0. 양방향 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 unordered iteration(s)` · 0.06초. 대가: 새 CI 잡 하나(제안이 real weight 라 부른 그것) · 측정된 사각 1건 — 튜플 언패킹으로 받은 set 은 못 본다(`ci_runs, ci_tcs = parse_ci(...)` 에 정렬 없는 순회를 넣으면 rc 0 통과). 후속 제안 #p0 으로 남겼다. DoD 10명령 rc 0. Co-Authored-By: Claude Opus 5 --- .github/workflows/rust.yml | 9 ++ CLAUDE.md | 1 + REPORT.md | 10 ++ STATE.md | 6 + .../2026-09-20-lock-script-output-order.json | 43 ++++++ .../2026-09-20-lock-script-output-order.md | 52 +++++++ scripts/check-script-output-order.py | 132 ++++++++++++++++++ 7 files changed, 253 insertions(+) create mode 100644 docs/worklog/2026-09-20-lock-script-output-order.json create mode 100644 docs/worklog/2026-09-20-lock-script-output-order.md create mode 100644 scripts/check-script-output-order.py diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 30aeeee4..d7701949 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -81,6 +81,15 @@ jobs: - uses: actions/checkout@v7 - run: python3 scripts/check-dod-ci-parity.py + # a checker that iterates a set prints its findings in a different order on different runs, so two + # rounds cannot diff their output — and every other axis stays green while it does. This reads the + # checkers' AST rather than re-running them. One runner, not the matrix. + script_output_order: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: python3 scripts/check-script-output-order.py + # Jvm::exception unwraps new_class(), so naming a class the loader cannot resolve panics instead # of throwing. This compares the names against the registered protos (see the script's docstring # for what it cannot see). One runner, not the matrix. diff --git a/CLAUDE.md b/CLAUDE.md index d83ee6a9..2effba1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ python3 scripts/check-dod-ci-parity.py python3 scripts/check-named-exception-classes-are-loadable.py python3 scripts/check-merge-dropped-symbols.py + python3 scripts/check-script-output-order.py ``` ★★**이 블록은 이제 «기계가 지킨다» — `scripts/check-dod-ci-parity.py`(CI job `dod_parity`)가 이 코드블록과 `rust.yml` 을 «각각 파싱해» 대칭차를 낸다.** 어긋나면 그 자리에서 red 다. diff --git a/REPORT.md b/REPORT.md index 9bf2727d..a2297c83 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,14 @@ # REPORT +## [2026-09-20] 검사기 출력 순서를 «잠갔다» — 습관을 규칙으로 (rustjava-checker-output-determinism-has-no-guard) +- 무엇을: 채택 제안 `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번째 줄. +- 왜: 전 회차가 한 단어로 고친 비결정성을 ★**아무도 잠그지 않았다**. ★**「그물 0」을 이 트리에서 재현했다** — 제품 호출부에 비결정성을 되돌려 놓고 재니 파이썬 검사기 **4종 전건 rc 0** · `cargo fmt` **rc 0**. ★해소 여부도 먼저 쟀다: `scripts/`·`rust.yml` 최종 커밋은 **`35f34797`**(그 수정 자신) · 추적 파일의 `PYTHONHASHSEED` 는 **산문과 주석뿐**이다. +- ★**왜 «정적»인가 — 제안이 제시한 두 대안을 둘 다 쓰지 않았다.** ⒜**두 `PYTHONHASHSEED` 재실행**: set 은 해시 순서가 정렬 순서와 «다를 때만» 보이므로, 사고의 경로 2개에서 2회 비교는 ★**회차당 약 절반 눈을 감는다**(느린 것이 문제가 아니다). ⒝**`assert sorted`**: 제안 자신이 적은 약점 — 「다른 곳의 두 번째 출처를 못 잡는다」. ⇒ 정적 축은 **둘 다 갖지 않는다**(재실행 0 · 새 출처 포착을 M2 로 실증). +- ★**양방향 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초**. +- ★**대가**: ⒜**새 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 에서 잡힌다). +- ★**제안보다 넓힌 곳 하나**: `target` 은 「scripts/ (all four checkers)」인데 glob 을 **`scripts/*.py`** 로 썼다 — 한 단어 차이이고 포함된 **7개 전건이 오늘 통과**한다. +- 검증: DoD 10명령 rc 0 · `dod_parity` 「명령 9개 · toolchain 2개로 둘 다 일치」. +- ★후속 추천: **튜플 언패킹 반환으로 오는 set 을 따라가라**(S · 위 ⒝의 그 구멍). 상세 = `docs/worklog/2026-09-20-lock-script-output-order.{md,json}`. + ## [2026-09-19] 오류 경로의 «또 하나»는 `java/lang/String` 이고 — ★**재귀한다**(rustjava-error-path-needs-java-lang-string-measure-first) - 무엇을: 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`(worklog json 기록). ★**제안의 조건이 「측정이 먼저」였고 그대로 했다** — 선행 회차가 String 에 대해 **아무것도 주장하지 않았고**(자기 worklog 에 「실측 아님」이라 적었다) 셋 중 무엇인지가 열려 있었다. - ★★**답 = ⒜ 재귀한다**(⒝ 깨끗한 실패도, ⒞ 이미 상주도 아니다). 하니스로 String 을 숨겨 이분법으로 경계를 찾았다: 상한 **116 생존 ↔ 117 `stack overflow, aborting`(SIGABRT · rc 134)** · ★**양쪽 2회씩 재현** · ★**상한 100000 도 abort** ⇒ 상한은 «하니스가 양보하는 지점»이지 **바닥이 아니다**. diff --git a/STATE.md b/STATE.md index 5d64d570..b4369785 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,12 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [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번째 줄. + ★**불변식 한 줄**: `scripts/*.py` 의 어떤 `for`·컴프리헨션도 **`sorted(...)` 밖에서 set 을 순회하지 않는다**. + ★**「그물 0」 재현**: 비결정성을 되돌린 채 파이썬 검사기 **4종 rc 0** · `cargo fmt` **rc 0**. ★해소 여부 선행 확인 — `scripts/`·`rust.yml` 최종 커밋은 **`35f34797`**(그 수정 자신). + ★**정적을 고른 이유**: 두 `PYTHONHASHSEED` 재실행은 ★**회차당 약 절반 눈을 감고**(해시 순서 = 정렬 순서면 무증상), `assert sorted` 는 **다른 곳의 새 출처를 못 잡는다**(제안 자신의 약점 기술). + ★**양방향 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초. + ★**측정된 사각**: 튜플 언패킹 반환 set 은 못 본다 — `ci_runs, ci_tcs = parse_ci(...)` 에 정렬 없는 순회를 넣으니 ★**rc 0 통과**. 후속 제안 `#p0`. - [rustjava-error-path-needs-java-lang-string-measure-first] ★★**오류 경로의 또 하나 `java/lang/String` — ⒜ «재귀한다»로 확정하고 선재 확인을 넣었다.** 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`. ★제안의 조건이 「측정이 먼저」였고 그대로 했다. ★**실측**: 상한 **116 생존 ↔ 117 SIGABRT «stack overflow, aborting»**(양쪽 2회 재현) · ★**상한 100000 도 abort** ⇒ 바닥이 없다. ★**⒞ 아님을 구조로**: `bootstrap_classes` **6개에 String 없음** · `from_rust_class` 는 이름을 **`[B`(nameBytes)** 로 넣는다 ⇒ 처음 필요한 곳은 프로퍼티 루프. diff --git a/docs/worklog/2026-09-20-lock-script-output-order.json b/docs/worklog/2026-09-20-lock-script-output-order.json new file mode 100644 index 00000000..44b228d6 --- /dev/null +++ b/docs/worklog/2026-09-20-lock-script-output-order.json @@ -0,0 +1,43 @@ +{ + "date": "2026-09-20", + "taskId": "rustjava-checker-output-determinism-has-no-guard", + "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.", + "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.", + "measurements": { + "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", + "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", + "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", + "unordered_iterations_today": 0, + "ci_cost": "one new job (script_output_order), checkout + python3, no toolchain -- same shape as the other four doc/script jobs", + "runtime": "0.06s over 7 files" + }, + "verification": { + "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", + "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", + "green_baseline": "rc 0, `7 script(s): 0 unordered iteration(s) that could reach output`", + "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.", + "dod": "all 10 DoD commands rc 0; dod_parity reports 9 commands / 2 toolchains matching on both sides" + }, + "changes": [ + "scripts/check-script-output-order.py: new, ~60 lines of ast walk (set-valued names/functions to a fixpoint, then every For/comprehension iterable)", + ".github/workflows/rust.yml: new job script_output_order", + "CLAUDE.md: the DoD block gains the 10th command (dod_parity requires both sides to move together)" + ], + "issues": [ + "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." + ], + "adoptedProposals": [ + "2026-09-19-merge-drops-deterministic-order#p0" + ], + "proposals": [ + { + "title": "Close the one measured hole in the output-order lock: sets that arrive by tuple-unpacked call return", + "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.", + "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.", + "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.", + "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.", + "effort": "S", + "target": "scripts/check-script-output-order.py" + } + ] +} diff --git a/docs/worklog/2026-09-20-lock-script-output-order.md b/docs/worklog/2026-09-20-lock-script-output-order.md new file mode 100644 index 00000000..821e4011 --- /dev/null +++ b/docs/worklog/2026-09-20-lock-script-output-order.md @@ -0,0 +1,52 @@ +# 2026-09-20 — 검사기 출력 순서를 «잠갔다» + +채택 제안 `2026-09-19-merge-drops-deterministic-order#p0`. +티켓 `rustjava-checker-output-determinism-has-no-guard`. + +## 먼저 잰 것 — 아직 안 고쳐졌나 + +- `scripts/`·`rust.yml` 을 만진 마지막 커밋은 **`35f34797`**, 그 한 줄 수정 자신이다. 잠금은 없다. +- 추적 파일에서 `PYTHONHASHSEED` 는 **산문(REPORT·STATE·worklog)과 고쳐진 스크립트의 주석에만** 있다 — + 어떤 검사에도 없다. +- 그물이 «0» 이라는 제안의 주장을 **이 트리에서 재현**: 제품 호출부에 비결정성을 되돌려 놓고 재니 + 파이썬 검사기 **4종 전건 rc 0** · `cargo fmt --all -- --check` **rc 0**. + +## 무엇을 했나 + +`scripts/check-script-output-order.py` — **`scripts/*.py` 의 어떤 `for`·컴프리헨션도 +`sorted(...)` 밖에서 set 을 순회하지 않는다**를 AST 로 단언한다. 새 CI 잡 `script_output_order` +하나 + DoD 블록 10번째 줄(대칭 잠금이 둘을 함께 움직이게 한다). + +## 왜 «정적»인가 — 제안이 제시한 두 대안을 다 쓰지 않았다 + +- **두 `PYTHONHASHSEED` 로 재실행**: set 은 해시 순서가 정렬 순서와 «다를 때만» 눈에 보인다. + 2026-09-19 사고의 경로 2개에서는 그 확률이 약 절반이라, 2회 비교는 회차당 동전 던지기다. + 느린 것이 문제가 아니라 **절반은 눈을 감는다**는 것이 문제다. +- **`assert sorted` 로 직접 단언**: 제안 자신이 적은 약점 그대로 — 「다른 곳에 생긴 두 번째 출처를 + 못 잡는다」. 정적 축은 그 두 약점을 **둘 다** 갖지 않는다(재실행 0 · 새 출처 포착 · M2 로 실증). + +## 양방향 — 두 개악, 두 복원 (전부 제품 호출부 · 사본 아님) + +| | 개악 | 결과 | +|---|---|---| +| M1 | `check-merge-dropped-symbols.py:254` 의 `sorted()` 제거 | **rc 1** · 그 파일·줄을 지목 → 복원 **rc 0** | +| M2 | `check-dod-ci-parity.py:215` `for c in sorted(only_ci)` → `for c in only_ci` (★**다른 파일의 새 출처**) | **rc 1** · 그 파일·줄을 지목 → 복원 **rc 0** | + +정상 = `7 script(s): 0 unordered iteration(s) that could reach output` · rc 0 · 0.06초. + +## 대가 — 숨기지 않는다 + +- **새 CI 잡 하나**. 제안이 「real weight」라고 부른 그것이고, 값을 깎지 않았다(툴체인 없는 + checkout + `python3` 두 줄 = 기존 doc 잡 4개와 같은 형상). +- ★**측정된 사각 1건**: 튜플 언패킹으로 함수 반환을 받은 set 은 이 패스가 못 본다. + 오늘 실제로 하나 있다 — `ci_runs, ci_tcs = parse_ci(...)`. 거기에 정렬 없는 `for t in ci_tcs:` 를 + 넣어 보니 잠금은 **rc 0** 으로 통과했다. 지금 그 이름은 전부 `sorted()`/집합 연산으로만 읽히므로 + 틀린 곳은 없지만, **구멍은 진짜다.** 후속 제안 `#p0` 으로 남겼다(문서에만 두면 썩는 계급이다). +- **dict 는 안 본다**: 삽입 순서를 지키므로 결정성은 그것을 만든 쪽에 달렸고, set 이 dict 를 먹이면 + set 에서 잡힌다. + +## 제안 문면보다 넓힌 곳 한 군데 + +제안의 `target` 은 「scripts/ (all four checkers)」였는데 glob 을 `scripts/*.py` 로 썼다 — +한 단어 차이이고, 포함된 7개(감사·조사 스크립트 2개 + 이 파일 포함) **전건이 오늘 통과**한다. +회차 간 diff 를 하는 것은 검사기만이 아니다. diff --git a/scripts/check-script-output-order.py b/scripts/check-script-output-order.py new file mode 100644 index 00000000..105393ad --- /dev/null +++ b/scripts/check-script-output-order.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Refuse an unordered iteration that can reach a checker's output. + +Why: on 2026-09-19 `check-merge-dropped-symbols.py` iterated a set of paths, so two runs of the +same command printed the same six findings in two different orders — Python's per-process string +hash seed decides. A before/after diff read as a regression until the unchanged version was shown +to disagree with itself. The fix was one word, `sorted(...)`, and nothing locks it: measured that +round, removing it again leaves `cargo fmt`, `cargo clippy`, `cargo test` and all four python +checkers green. The guard was zero, which is why this file exists. + +What it asserts, in one line a reader can check: **no `for` loop or comprehension in +`scripts/*.py` iterates a set unless it is inside `sorted(...)`.** + +Why static rather than re-running under two `PYTHONHASHSEED`s: an unordered set is only *visibly* +unordered when the hash order happens to differ from the sorted one, so a two-run comparison is a +coin flip per run — with the two paths of the real incident it agrees with itself about half the +time. This axis has no such gap: it does not need the bug to be observable to see it, it costs no +re-run, and it fires on a *new* set appearing anywhere in these files, not only on the one line the +2026-09-19 round fixed. + +Blind spots, stated rather than implied: + * Dicts are not flagged. They iterate in insertion order, so their output order is as + deterministic as whatever built them — a set feeding a dict is caught at the set. + * The set-ness of a value is inferred syntactically (a `set()`/`{…}`/set comprehension, a set + operator, a name or a local function that carries one). A set arriving from something this + cannot see — a tuple-unpacked call return, an import, a parameter — is missed, and one such + name exists today: `ci_runs, ci_tcs = parse_ci(...)` in `check-dod-ci-parity.py` binds a set + this pass does not know about (it is only ever read through `sorted()` or a set operator, so + nothing is wrong there now, but an unsorted iteration of it would pass). It is a check for the + shape that actually bit us, not a type system. + * Every `scripts/*.py`, not just the CI checkers: the surveys get diffed across rounds too, and + they cost nothing to include — all of them pass today. + +Exit: 0 every iteration is ordered, 1 at least one is not, 2 the files it checks are not there. +""" + +import ast +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SET_OPS = (ast.BitOr, ast.BitAnd, ast.Sub, ast.BitXor) + + +def produces_set(node, names, funcs): + """Is this expression a set, as far as syntax can tell.""" + if isinstance(node, (ast.Set, ast.SetComp)): + return True + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + return node.func.id in ("set", "frozenset") or node.func.id in funcs + if isinstance(node, ast.BinOp) and isinstance(node.op, SET_OPS): + return produces_set(node.left, names, funcs) or produces_set(node.right, names, funcs) + if isinstance(node, ast.Name): + return node.id in names + return False + + +def set_values(tree): + """(names, functions) that carry a set. Iterated to a fixpoint because one feeds the other: + `found = set()` makes `symbols()` a set-returning function, which makes `theirs_symbols` a set. + """ + names, funcs = set(), set() + growing = True + while growing: + growing = False + before = len(names) + len(funcs) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + pairs = [] + if isinstance(node.value, ast.Tuple) and isinstance(node.targets[0], ast.Tuple): + pairs = list(zip(node.targets[0].elts, node.value.elts)) # a, b = set(), [] + else: + pairs = [(t, node.value) for t in node.targets] + for target, value in pairs: + if isinstance(target, ast.Name) and produces_set(value, names, funcs): + names.add(target.id) + elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name): + if produces_set(node.value, names, funcs): + names.add(node.target.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for inner in ast.walk(node): + if isinstance(inner, ast.Return) and inner.value is not None and produces_set(inner.value, names, funcs): + funcs.add(node.name) + growing = len(names) + len(funcs) > before + return names, funcs + + +def unordered_iterations(tree, names, funcs): + """[(line, source)] for every iteration over a set that is not wrapped in sorted().""" + iterables = [node.iter for node in ast.walk(tree) if isinstance(node, (ast.For, ast.AsyncFor))] + iterables += [gen.iter for node in ast.walk(tree) for gen in getattr(node, "generators", [])] + found = [] + for iterable in iterables: + pending = [iterable] + while pending: + node = pending.pop() + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "sorted": + continue # anything under a sorted() is ordered, however it was built + if produces_set(node, names, funcs): + found.append((iterable.lineno, ast.unparse(iterable))) + break + pending.extend(ast.iter_child_nodes(node)) + return sorted(found) + + +def main(): + scripts = sorted((ROOT / "scripts").glob("*.py")) + if not scripts: + # A move must not turn this into a green run over nothing — the sibling lineage's + # whole subject is checks that pass by looking at zero things. + print("cannot measure: no scripts/*.py to read", file=sys.stderr) + return 2 + + total = 0 + for path in scripts: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + names, funcs = set_values(tree) + bad = unordered_iterations(tree, names, funcs) + for line, source in bad: + print(f"✗ {path.relative_to(ROOT)}:{line}: iterates a set — two runs can print this in two orders") + print(f" {source}") + print(" wrap the iterable in sorted(), as check-merge-dropped-symbols.py does") + total += len(bad) + if not bad: + print(f" ✓ {path.relative_to(ROOT)}") + + print(f"{len(scripts)} script(s): {total} unordered iteration(s) that could reach output") + return 1 if total else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3dbaaaf40409cc4081175fa770b4a4f0d2b51f6c Mon Sep 17 00:00:00 2001 From: jun0 Date: Sun, 20 Sep 2026 13:54:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[rustjava-checker-output-determinism-has-no?= =?UTF-8?q?-guard]=20fix(scripts):=20=EB=84=93=EC=9D=80=20=EC=95=BD?= =?UTF-8?q?=EC=86=8D=EC=9D=84=20=EC=A2=81=EA=B2=8C=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?join=C2=B7*=EC=96=B8=ED=8C=A9=EC=9D=84=20=EB=8D=94=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EA=B1=B0=EC=A7=93=20=EB=8B=A8=EC=96=B8=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게이트² request-changes(PR #85 · pin 8a633bc8) 승계. 검수자가 반례 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 --- REPORT.md | 2 + STATE.md | 4 + .../2026-09-20-lock-script-output-order.json | 5 +- .../2026-09-20-lock-script-output-order.md | 31 +++++++ scripts/check-script-output-order.py | 83 +++++++++++++------ 5 files changed, 100 insertions(+), 25 deletions(-) diff --git a/REPORT.md b/REPORT.md index a2297c83..89bd5b08 100644 --- a/REPORT.md +++ b/REPORT.md @@ -6,6 +6,8 @@ - ★**양방향 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초**. - ★**대가**: ⒜**새 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 에서 잡힌다). - ★**제안보다 넓힌 곳 하나**: `target` 은 「scripts/ (all four checkers)」인데 glob 을 **`scripts/*.py`** 로 썼다 — 한 단어 차이이고 포함된 **7개 전건이 오늘 통과**한다. +- ★★**게이트² 반려 승계**(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 잡 «하나»**다(+자기 글롭에 자기가 들어가 한 축으로 자신을 읽는 것). ★하네스는 **만들지 않았다 — 적었다.** + ★**승계 양방향**(제품 호출부 글롭 그대로): `join`·`*` 반례 **rc 1 · 줄 지목** ↔ `sorted()` 씌운 둘은 **무검출**(오탐 0) ↔ 반례 제거 시 **rc 0**. 원 M1·M2 **회귀 재확인**(각 rc 1 ↔ 복원 rc 0). - 검증: DoD 10명령 rc 0 · `dod_parity` 「명령 9개 · toolchain 2개로 둘 다 일치」. - ★후속 추천: **튜플 언패킹 반환으로 오는 set 을 따라가라**(S · 위 ⒝의 그 구멍). 상세 = `docs/worklog/2026-09-20-lock-script-output-order.{md,json}`. diff --git a/STATE.md b/STATE.md index b4369785..433c9cb2 100644 --- a/STATE.md +++ b/STATE.md @@ -13,6 +13,10 @@ ★**정적을 고른 이유**: 두 `PYTHONHASHSEED` 재실행은 ★**회차당 약 절반 눈을 감고**(해시 순서 = 정렬 순서면 무증상), `assert sorted` 는 **다른 곳의 새 출처를 못 잡는다**(제안 자신의 약점 기술). ★**양방향 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초. ★**측정된 사각**: 튜플 언패킹 반환 set 은 못 본다 — `ci_runs, ci_tcs = parse_ci(...)` 에 정렬 없는 순회를 넣으니 ★**rc 0 통과**. 후속 제안 `#p0`. + ★★**게이트² 반려 승계(`-fix`)**: **R1** `", ".join(myset)`·`print(*myset)` 이 통과했다(사고와 **같은 계급** · 미기재) ⇒ `str.join` 첫 인자 + `Starred`(Load) 를 검사에 더하고 약속 문구를 ★**«for/컴프리헨션 · str.join · \*-언팩» 세 위치로 명시** · + **R2** 「set→dict 는 set 에서 잡힌다」가 **거짓**(`dict.fromkeys`)이라 **삭제**. ★`fromkeys` 만 반쪽으로 잡지 «않았다» — 진짜 계급은 «컨테이너 순서 오염»이고 하나만 잡으면 **없는 프로그램을 있는 것처럼 보이게 한다**(오늘 0건 ⇒ 문안 결함) · + **R3** ★**python 린터·포매터·테스트 0**(추적 파일 0건 · `rust.yml` 은 검사기 5회 실행뿐) ⇒ **이 파일을 보는 기계는 CI 잡 «하나»**임을 빚으로 적었다(하네스는 만들지 «않았다»). + ★승계 양방향: `join`·`*` 반례 **rc 1** ↔ `sorted()` 씌우면 **무검출**(오탐 0) ↔ 반례 제거 **rc 0** · 원 M1·M2 **회귀 재확인**. - [rustjava-error-path-needs-java-lang-string-measure-first] ★★**오류 경로의 또 하나 `java/lang/String` — ⒜ «재귀한다»로 확정하고 선재 확인을 넣었다.** 채택 제안 `2026-09-19-fallback-class-absence-fails-at-construction#p0`. ★제안의 조건이 「측정이 먼저」였고 그대로 했다. ★**실측**: 상한 **116 생존 ↔ 117 SIGABRT «stack overflow, aborting»**(양쪽 2회 재현) · ★**상한 100000 도 abort** ⇒ 바닥이 없다. ★**⒞ 아님을 구조로**: `bootstrap_classes` **6개에 String 없음** · `from_rust_class` 는 이름을 **`[B`(nameBytes)** 로 넣는다 ⇒ 처음 필요한 곳은 프로퍼티 루프. diff --git a/docs/worklog/2026-09-20-lock-script-output-order.json b/docs/worklog/2026-09-20-lock-script-output-order.json index 44b228d6..b6ed44ad 100644 --- a/docs/worklog/2026-09-20-lock-script-output-order.json +++ b/docs/worklog/2026-09-20-lock-script-output-order.json @@ -24,7 +24,10 @@ "CLAUDE.md: the DoD block gains the 10th command (dod_parity requires both sides to move together)" ], "issues": [ - "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." + "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.", + "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/.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.", + "Order laundered through a container is not followed (dict.fromkeys, list() 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.", + "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()." ], "adoptedProposals": [ "2026-09-19-merge-drops-deterministic-order#p0" diff --git a/docs/worklog/2026-09-20-lock-script-output-order.md b/docs/worklog/2026-09-20-lock-script-output-order.md index 821e4011..95b88215 100644 --- a/docs/worklog/2026-09-20-lock-script-output-order.md +++ b/docs/worklog/2026-09-20-lock-script-output-order.md @@ -45,6 +45,37 @@ - **dict 는 안 본다**: 삽입 순서를 지키므로 결정성은 그것을 만든 쪽에 달렸고, set 이 dict 를 먹이면 set 에서 잡힌다. +## ★게이트² 반려 승계(PR #85 `8a633bc8` · request-changes) — 세 가지를 고쳤다 + +- **R1 — 넓은 약속, 좁은 검사**: `", ".join(myset)` 과 `print(*myset)` 이 **rc 0 으로 통과**했다(검수자 probe `p09`). + ★2026-09-19 사고와 **같은 계급**(경로 set 이 한 줄로 찍힌다)인데 blind spot 에도 없었다. + ⇒ 검수자의 ⑴을 골랐다 — `str.join` 의 **첫 인자**와 `ast.Starred`(Load)의 **value** 를 검사 대상에 더했다(순증 ~10줄). + ★**⑵(범위 축소)가 아니라 ⑴을 고른 이유**: 그 둘은 파이썬에서 set 이 `for` 없이 출력에 닿는 **가장 흔한 두 철자**이고, + 더하는 값이 열 줄이라 **약속을 지키는 쪽이 더 싸다**. 약속 문구도 함께 고쳤다 — 이제 「iteration」이 아니라 + ★**「`for`/컴프리헨션 · `str.join` · `*`-언팩 **이 셋**」**이라고 적는다(출력 줄도 같은 문면). +- **R2 — docstring 의 dict 단언이 «거짓»이었다**: 「a set feeding a dict is caught at the set」 → + `d = dict.fromkeys(myset)` 뒤 `for k in d:` 는 **통과한다**(probe `p02`). ★그 줄을 **지웠다**. + ★**`fromkeys` 만 두 줄로 잡지 «않았다»** — 진짜 계급은 «컨테이너를 통한 순서 오염»(`list(myset)` 을 이름에 묶기, + `bag["k"]`)이고, 그 가족 중 하나만 잡으면 ★**없는 프로그램을 있는 것처럼 보이게 한다.** 그것이 R2 가 지적한 실패 그대로다. + ※오늘 `scripts/` 의 `dict.fromkeys` **0건** ⇒ **문안 결함이지 live 오검이 아니다.** +- **R3 — 빚 한 줄**: ★**이 repo 는 python 린터·포매터·테스트가 «0»이다**(git 추적 파일 중 + `pyproject|setup.cfg|.pre-commit|tox.ini|ruff|flake8|requirements` **0건** · `rust.yml` 의 python 은 + `python3 scripts/<검사기>.py` **5회 실행뿐, lint step 없음**). ⇒ ★**이 파일을 기계로 보는 것은 CI 잡 «하나»**이고, + 그 밖에는 자기 자신이 `scripts/*.py` 글롭에 들어가 **한 축으로 스스로를 읽는 것**이 전부다. 하네스는 **만들지 않았다** + (제안 자신이 「하네스는 «결정»이지 «한 줄»이 아니다」로 규모를 적었다) — **적었다.** +- **그 밖(검수자 기록분)도 docstring 에 넣었다**: 이름에 **스코프가 없다**(`found` 충돌 시 리스트 순회가 **틀린 red**) · + 메서드형 집합연산(`a.difference(b)`)은 못 본다 · `while s: s.pop()` 드레인은 못 본다 · `growing = False` **죽은 줄** 제거. + +### 승계 회차 양방향 — ★**제품 호출부(`scripts/` 글롭) 그대로** +| 반례 | 고침 전(검수자 실측) | 고침 후(내 실측) | +|---|---|---| +| `", ".join(myset)` | rc 0 **통과** | ★**rc 1 · 줄 지목** | +| `print(*myset)` | rc 0 **통과** | ★**rc 1 · 줄 지목** | +| `", ".join(sorted(myset))` · `print(*sorted(myset))` | — | ★**무검출**(오탐 0) | +| `dict.fromkeys(myset)` → `for k in d` | rc 0 통과 | **여전히 통과**(★blind spot 으로 «적었다» · 숨기지 않았다) | +| 반례 파일 제거 | — | **rc 0 · 7 script(s) · 0** | +★원 M1(`check-merge-dropped-symbols.py:254`)·M2(`check-dod-ci-parity.py:215`) **회귀 재확인**: 각각 **rc 1** ↔ 복원 **rc 0**. + ## 제안 문면보다 넓힌 곳 한 군데 제안의 `target` 은 「scripts/ (all four checkers)」였는데 glob 을 `scripts/*.py` 로 썼다 — diff --git a/scripts/check-script-output-order.py b/scripts/check-script-output-order.py index 105393ad..856f4573 100644 --- a/scripts/check-script-output-order.py +++ b/scripts/check-script-output-order.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Refuse an unordered iteration that can reach a checker's output. +"""Refuse a set read in a position whose order reaches a checker's output. Why: on 2026-09-19 `check-merge-dropped-symbols.py` iterated a set of paths, so two runs of the same command printed the same six findings in two different orders — Python's per-process string @@ -8,8 +8,14 @@ round, removing it again leaves `cargo fmt`, `cargo clippy`, `cargo test` and all four python checkers green. The guard was zero, which is why this file exists. -What it asserts, in one line a reader can check: **no `for` loop or comprehension in -`scripts/*.py` iterates a set unless it is inside `sorted(...)`.** +What it asserts, in one line a reader can check: **in `scripts/*.py`, no set is read in one of the +three positions whose order reaches output — the iterable of a `for` or a comprehension, the first +argument of `str.join`, or a `*`-unpacking — unless it is inside `sorted(...)`.** + +The last two were added after a review wrote `", ".join(myset)` and `print(*myset)` and watched both +pass. That is the 2026-09-19 incident exactly, minus the loop: a set of paths printed in hash order. +Three positions is not "every position", and the sentence above says so rather than promising a +coverage this does not have — the list below is the rest. Why static rather than re-running under two `PYTHONHASHSEED`s: an unordered set is only *visibly* unordered when the hash order happens to differ from the sorted one, so a two-run comparison is a @@ -18,20 +24,31 @@ re-run, and it fires on a *new* set appearing anywhere in these files, not only on the one line the 2026-09-19 round fixed. -Blind spots, stated rather than implied: - * Dicts are not flagged. They iterate in insertion order, so their output order is as - deterministic as whatever built them — a set feeding a dict is caught at the set. - * The set-ness of a value is inferred syntactically (a `set()`/`{…}`/set comprehension, a set - operator, a name or a local function that carries one). A set arriving from something this - cannot see — a tuple-unpacked call return, an import, a parameter — is missed, and one such - name exists today: `ci_runs, ci_tcs = parse_ci(...)` in `check-dod-ci-parity.py` binds a set - this pass does not know about (it is only ever read through `sorted()` or a set operator, so - nothing is wrong there now, but an unsorted iteration of it would pass). It is a check for the - shape that actually bit us, not a type system. +Blind spots — listed rather than implied, and none of them is a claim of safety: + * **Order laundered through a container is not followed.** `d = dict.fromkeys(myset)` and then + `for k in d` keeps the set's order and passes; so do `y = list(myset)` then `for x in y`, and + `bag["k"] = myset` then `for x in bag["k"]`. An earlier version of this file said "a set feeding + a dict is caught at the set" — that was **false**, shown by a review that ran it, and a wrong + blind-spot entry is worse than a missing one because a reader takes it as a guarantee. Measured + on this tree: `dict.fromkeys` appears **0 times**, so this is a hole in the promise and not a + live miss. Closing it properly means following order taint through containers, which is a + different program from this one; doing `fromkeys` alone would buy the *look* of that program for + two lines, which is the failure this paragraph is about. + * **Set-ness is inferred syntactically** — a `set()`/`{…}`/set comprehension, a set *operator*, or + a name or local function carrying one. So a set that arrives some other way is missed: a + tuple-unpacked call return, an import, a parameter. One such name exists today, + `ci_runs, ci_tcs = parse_ci(...)` in `check-dod-ci-parity.py` — read only through `sorted()` or a + set operator, so nothing is wrong there now, but an unsorted read of it would pass. Method- + spelled set operations (`a.difference(b)`) are not read either, only the operators (`a - b`). + * **Names have no scope.** `found = set()` in one function and `found = [...]` in another make the + *list* read go red. No such collision exists today, but `found` is one of the names from the + original incident, so the false red is reachable — and a false red invites a wrong `sorted()`, + which is a worse outcome than a miss. + * **Draining is not reading.** `while s: s.pop()` takes a set in hash order and passes (0 today). * Every `scripts/*.py`, not just the CI checkers: the surveys get diffed across rounds too, and they cost nothing to include — all of them pass today. -Exit: 0 every iteration is ordered, 1 at least one is not, 2 the files it checks are not there. +Exit: 0 nothing found, 1 at least one unordered read, 2 the files it checks are not there. """ import ast @@ -62,7 +79,6 @@ def set_values(tree): names, funcs = set(), set() growing = True while growing: - growing = False before = len(names) + len(funcs) for node in ast.walk(tree): if isinstance(node, ast.Assign): @@ -85,12 +101,29 @@ def set_values(tree): return names, funcs -def unordered_iterations(tree, names, funcs): - """[(line, source)] for every iteration over a set that is not wrapped in sorted().""" - iterables = [node.iter for node in ast.walk(tree) if isinstance(node, (ast.For, ast.AsyncFor))] - iterables += [gen.iter for node in ast.walk(tree) for gen in getattr(node, "generators", [])] + +def order_reaching_output(tree): + """Every expression whose element order can end up in a printed line. + + Three shapes, and the docstring's promise is exactly these three: what a `for` or comprehension + walks, what `str.join` is handed, and what a `*` spreads. A `Starred` in a target + (`a, *rest = ...`) is a Store and is not one of them. + """ + for node in ast.walk(tree): + if isinstance(node, (ast.For, ast.AsyncFor)): + yield node.iter + for generator in getattr(node, "generators", []): + yield generator.iter + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "join" and node.args: + yield node.args[0] + if isinstance(node, ast.Starred) and isinstance(node.ctx, ast.Load): + yield node.value + + +def unordered_reads(tree, names, funcs): + """[(line, source)] for every set read in one of those positions without a sorted() over it.""" found = [] - for iterable in iterables: + for iterable in order_reaching_output(tree): pending = [iterable] while pending: node = pending.pop() @@ -115,16 +148,18 @@ def main(): for path in scripts: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) names, funcs = set_values(tree) - bad = unordered_iterations(tree, names, funcs) + bad = unordered_reads(tree, names, funcs) for line, source in bad: - print(f"✗ {path.relative_to(ROOT)}:{line}: iterates a set — two runs can print this in two orders") + print(f"✗ {path.relative_to(ROOT)}:{line}: reads a set — two runs can print this in two orders") print(f" {source}") - print(" wrap the iterable in sorted(), as check-merge-dropped-symbols.py does") + print(" wrap it in sorted(), as check-merge-dropped-symbols.py does") total += len(bad) if not bad: print(f" ✓ {path.relative_to(ROOT)}") - print(f"{len(scripts)} script(s): {total} unordered iteration(s) that could reach output") + # The count names the three positions it looked at rather than claiming "could reach output": + # those are not the same set, and the docstring's blind spots are the difference. + print(f"{len(scripts)} script(s): {total} set(s) read unordered in a for/comprehension, str.join or *unpacking") return 1 if total else 0