diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index cc90be0b..ba4932c0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -61,3 +61,11 @@ jobs: steps: - uses: actions/checkout@v7 - run: python3 scripts/check-worklog-json.py + + # parity lock: the CLAUDE.md DoD block must reproduce this file's checks (see the script's + # docstring — hand comparison failed five rounds in a row). One runner, not the matrix. + dod_parity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: python3 scripts/check-dod-ci-parity.py diff --git a/CLAUDE.md b/CLAUDE.md index 40f16a8c..6ceb79d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ **검증기로 막을 수 없다.** ⇒ 이 조항은 **1차 방어**이며 **최종 방어는 diff 검토**다. ## Definition of Done -- ★**`.github/workflows/rust.yml` 이 «치는 그대로» 전부 green** — 축약하지 말고 이 여섯 줄을 그대로 쳐라: +- ★**`.github/workflows/rust.yml` 이 «치는 그대로» 전부 green** — 축약하지 말고 이 블록을 그대로 쳐라: ``` cargo fmt --all -- --check cargo clippy --all -- -D warnings @@ -30,7 +30,13 @@ cargo clippy --workspace --exclude test-utils --target wasm32-unknown-unknown -- -D warnings cargo test --all python3 scripts/check-worklog-json.py + python3 scripts/check-dod-ci-parity.py ``` + ★★**이 블록은 이제 «기계가 지킨다» — `scripts/check-dod-ci-parity.py`(CI job `dod_parity`)가 + 이 코드블록과 `rust.yml` 을 «각각 파싱해» 대칭차를 낸다.** 어긋나면 그 자리에서 red 다. + ⇒ ★**줄 수를 세어 문장에 적지 마라**(이 리니지가 그 수로 다섯 번 낡았다) — 세는 것은 검사기 몫이다: + `python3 scripts/check-dod-ci-parity.py` 가 「명령 N개 · toolchain N개로 «둘 다 일치»」를 찍는다. + ★**그 검사기가 «못 보는 것»도 그 자리에서 함께 찍는다**(조건부 step = OS 축) — 침묵하지 않는다. ★★**`+beta` 줄을 빼지 마라 — CI 는 이 검사들을 «6셀»(toolchain 2 × OS 3)로 친다.** `rust.yml` 의 `strategy.matrix.rust = [stable, beta]` 가 그 차원이고, 이 repo 엔 `rust-toolchain.toml` 이 **없어** 맨 `cargo` 는 **stable 1개**로만 돈다. ⇒ ★**toolchain 축을 안 적으면 「rust.yml 이 치는 그대로」가 «거짓»이 된다.** diff --git a/REPORT.md b/REPORT.md index b6d07cf5..3f2298fb 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,5 +1,26 @@ # REPORT +## [2026-09-04] 로컬 DoD ↔ CI 매트릭스 «기계 대조» 신설 (rustjava-local-dod-vs-ci-matrix-mechanical-check) +- 무엇을: `scripts/check-dod-ci-parity.py` 신설 + `rust.yml` job **`dod_parity`** 배선 + DoD 블록에 그 줄 추가. + ★**`CLAUDE.md` DoD 코드블록**과 **`.github/workflows/rust.yml`** 을 «각각 파싱해» **대칭차**를 낸다 — + 축 A(명령 집합) · 축 B(toolchain 집합). 오늘 **둘 다 0** 이라 계약대로 **rc=1(막는다)** 로 켰다. +- 왜: `…-going-stale` 리니지가 **다섯 회차** 내내 「전건 동기」를 주장했고 **매번 «또 한 자리»**가 나왔다 + (게이트②에서 6건째). ★**전부 같은 종류** — 「로컬 DoD 가 CI 검사 몇 개를 재현하는가」가 `rust.yml` 과 + 어긋난 채 문서에 굳었다. ⇒ ★**사람 손 대조로는 다섯 번 실패했다.** + ★★**그런데 «낡은 문자열 스캐너»는 만들지 않았다** — 게이트② 검수자가 「F1 은 «수»가 아니라 «말»이라 + 문자열 검사기로도 안 잡힌다」를 실측으로 세웠기 때문이다. ⇒ 문서의 문장이 아니라 ★**문서가 틀리는 «원인»**을 잡는다. +- 사용자 영향: 런타임 동작 **무변경**(`.rs` 변경 0). 회차가 DoD 를 축약하거나 CI 가 검사·매트릭스 차원을 + 늘렸는데 DoD 를 안 고치면 ★**그 PR 이 그 자리에서 red** 다 — 종전엔 «사람이 축을 돌려야» 드러났다. +- 검증: ★**개악 5건 전건 red · 무개악 green** — ⒜DoD wasm32 줄 제거 ⒝CI `- run: cargo doc --no-deps` 추가 + ⒞매트릭스 `nightly` 추가 ⒟DoD `+beta` 줄 제거 ⒠`rust-toolchain.toml` 신설(축 B 매핑 붕괴 가드). + DoD 7줄 전건 rc=0 · `cargo test --all` **554 / 0 / 1**(새 red 0). +- ★**한계를 숨기지 않는다 — 검사기가 «그 자리에서 함께» 찍는다**: ⒜**OS 축**(조건부 step)은 로컬 재현 불가라 + 여전히 CI 가 유일한 그물 ⒝두 축을 **«교차곱»으로 보지 않는다** — CI 는 cargo 검사 4종을 stable·beta 둘 다 + 치는데 DoD 는 `clippy` 만 이중이다. ★**이것은 2026-09-04 결정이 «고른 값»**이고(로컬 `cargo +beta test` = + 두 번째 toolchain 전면 재빌드 · lint 를 지는 축은 clippy 뿐), 넓히는 것은 «새 결정»이다. +- ★**후속 추천**: ⑴교차곱(모든 검사 × 모든 toolchain)까지 넓힐지 — ★**비용을 먼저 재고** 결정하라. + ⑵같은 형태의 파리티 락이 형제 repo(wie·qts)에도 필요한지 판정. + ## [2026-09-04] 「우리 자산이 낡는다」 상시 조항 판정 — 넣지 않고 «구멍 하나»를 막았다 (rustjava-sync-contract-standing-clause-for-our-assets-going-stale) - 무엇을: 운영자 채택 제안 ★**셋**(`2026-09-04-upstream-sync-s6#p1` · `…-s7#p1` · ★`…-s8#p0`)에 대한 **결정**이다. (★**[fix3 정정] 초판은 「둘」** — 같은 제안의 세 번째 판이 도착해 `adoptedProposals` 는 **세 ref** 다.) @@ -9,6 +30,8 @@ (`REPORT.md` · `STATE.md` · 워크로그 `.md`/`.json`). ★**[게이트② 정정] 초판은 「CI 검사 5종 중 wasm32 clippy 한 줄」이라 적었다** — 계수 «1» 시절 문면이고, 계수 **2** 정정 후에는 **두 축**(target · toolchain)이라 DoD 도 **6줄**이다. + ★★**[후속 정정] 그 «6줄»도 낡았다 — 파리티 검사기가 더해져 «7줄»이다.** + ⇒ ★**줄 수는 이제 문장이 아니라 `scripts/check-dod-ci-parity.py` 가 센다**(CI job `dod_parity`). - 왜: ★**목록 문서화로 시작하지 않고 «먼저 셌다»**(티켓이 그렇게 요구했다). 우리 자산 **8건**을 ★**돌연변이로 깨뜨려** 무엇이 잡는지 실측했다 — 경로 문자열·`setProperty` 서술자·charset 라우팅· `ClassFormatError` 종류 단정은 **`cargo test` RED**, 수동 span 은 **clippy RED**, diff --git a/STATE.md b/STATE.md index 465d4b6b..d43bc63e 100644 --- a/STATE.md +++ b/STATE.md @@ -23,6 +23,28 @@ (25번째 run, 앞선 24건 전부 red). **PR 대기 — 게이트③ 미착지.** ## 완료 +- [rustjava-local-dod-vs-ci-matrix-mechanical-check] ★★**로컬 DoD ↔ `rust.yml` «기계 대조» 신설 — + `scripts/check-dod-ci-parity.py` + CI job `dod_parity`.** ★**5회차 리니지의 상수를 «사람 손»에서 뺐다.** + ★★**설계 제약을 먼저 지켰다 — «낡은 문자열 스캐너»를 만들지 «않았다».** 게이트② 검수자가 실측으로 + 「F1 은 «수»가 아니라 «말»이라 문자열 검사기로도 안 잡힌다」를 세웠기 때문이다. ⇒ 문서의 «문장»이 아니라 + ★**문서가 틀리는 «원인»(두 집합이 갈린 것)**을 잡는다 — `CLAUDE.md` DoD 코드블록과 `rust.yml` 을 + **각각 파싱해 대칭차**를 낸다(★정본 위치는 문서가 아니라 **스크립트 안**에 박혀 있다 · 계약 3). + ★**축은 둘**: A=명령 집합 · B=toolchain 집합(`strategy.matrix.rust` ↔ `cargo +` 접두). + ★**오늘 대칭차 0** ⇒ 계약 2 대로 **경고가 아니라 rc=1(막는다)** 로 배선했다. + ★**검사기가 자기 줄을 «양쪽에» 넣는다** — CI job 과 DoD 블록 둘 다 `python3 scripts/check-dod-ci-parity.py` + 라 ★**자기 존재를 자기가 강제**한다. ⇒ DoD 는 6줄 → **7줄**. + ★★**개악 5건 전건 red · 무개악 green**(DoD 줄 제거 · CI `- run:` 추가 · 매트릭스 nightly 추가 · + DoD `+beta` 제거 · `rust-toolchain.toml` 신설) — ★**공허하지 않다.** + ★**침묵하지 않는다**: 성공에도 「명령 N개 · toolchain N개로 «둘 다 일치»」를 찍고, + ★**못 보는 것**(조건부 step = OS 축 · 두 축을 «교차곱»으로 보지 않는 것)도 **그 자리에서 함께** 찍는다. + ★**교차곱 미적용은 결함이 아니라 2026-09-04 결정이 «고른 값»이다**(`cargo +beta test` = 두 번째 + toolchain 전면 재빌드 · lint 를 지는 축은 clippy 뿐) — 넓히는 것은 «새 결정»이다. + ★**§4 의 셸 두 토막을 «지웠다»** — 같은 술어가 두 벌이면 다음 사람이 한쪽만 고친다(정본 = 스크립트). + ★**C7⒜(「그 축을 돌리는 것이 사람이다」)가 닫혔다** · ⒝(OS 축)는 그대로 열려 있다. + ★`.rs` 변경 0 · `allow` 9곳 무접촉 · upstream 무접촉. + ★게이트③ 완료: PR #26 — ★★**`--merge` 착지**(★`--squash` 아님 · 등재 repo). 머지커밋 sha 는 회신 `merged:` 참조. + ★**착지 후 반사실 확인**: `origin/main..upstream/main` = **0 유지**(스쿼시였으면 계보가 접혀 다시 벌어진다) · + ★**`origin/main` 에서 신설 job `dod_parity` success** 확인. - [rustjava-sync-contract-standing-clause-for-our-assets-going-stale] ★**「우리 자산이 낡는다」 상시 조항 «판정» — 결론: ★★넣지 «않는다». 대신 «구멍 하나»(로컬 DoD 가 CI 매트릭스를 재현하지 않던 것)를 막았다.** ★코드(`.rs`) 변경 0 · 규범 문서 2 + 기록 문서 4. @@ -38,6 +60,8 @@ ★★**근인은 「자산 목록이 없다」가 아니었다** — ★**로컬 DoD 가 CI 검사 5종 중 «wasm32 clippy» 한 줄을 빠뜨렸고** 그 줄이 그 자산이 사는 **유일한 자리**다. ⇒ `CLAUDE.md` DoD 가 이제 ★**CI 명령 5줄 + toolchain 축 1줄 = «6줄»**을 축약 없이 싣는다(★**[게이트② 정정] 「5줄」은 계수 «1» 시절 수다 — 빠진 것은 «줄 하나»가 아니라 «두 축»이었다**). + ★★**[후속 정정] 그 «6줄»도 낡았다 — 파리티 검사기가 더해져 «7줄»이다.** ⇒ ★**이제 줄 수를 문장에 적지 않는다**: + `scripts/check-dod-ci-parity.py`(CI job `dod_parity`)가 DoD 코드블록과 `rust.yml` 을 각각 파싱해 대칭차를 낸다. ★그리고 ②는 «조용히» 실패하지도 않는다(`excluded package(s) not found` + 빌드 실패) ⇒ 문제는 침묵이 아니라 **늦음**이다. ★**재개 조건은 «축 둘»이다**(정정): **축① `- run:` 줄** · ★**축② toolchain 매트릭스** — 세는 명령 둘 다 §4 에 박았다. ★**오늘의 값 = 축① 0 · 축② 0**(착수 시 축① **5** · 축② **1**). ★세 돌연변이(beta 제거·nightly 추가·`- run:` 추가)로 diff --git a/docs/upstream-sync-approach.md b/docs/upstream-sync-approach.md index 31fea7de..a649a31b 100644 --- a/docs/upstream-sync-approach.md +++ b/docs/upstream-sync-approach.md @@ -200,6 +200,8 @@ wasm32 clippy 한 줄」이었다.** 그것은 ★**계수 «1» 시절의 문 빠진 것은 «줄 하나»가 아니라 ★**«두 축»**(② target 축 = wasm32 `- run:` 줄 · ⑦ toolchain 축 = beta)이고, 둘은 ★**한 근인의 두 얼굴**이다. ⇒ 그래서 DoD 는 **5줄이 아니라 6줄**이다. +★★**[2026-09-04 후속 — `rustjava-local-dod-vs-ci-matrix-mechanical-check`] 위 «6줄»은 «수»이고, 그 수가 이 리니지를 다섯 번 낡게 했다.** DoD 는 그 뒤 `python3 scripts/check-dod-ci-parity.py` 가 더해져 **7줄**이 됐다. ⇒ ★**이제 줄 수를 문장에 적지 않는다** — `scripts/check-dod-ci-parity.py`(CI job `dod_parity`)가 DoD 코드블록과 `rust.yml` 을 **각각 파싱해** 대칭차를 내고, 어긋나면 **red** 다. + ★**왜 조항이 아닌가 — «세고» 정했다**(먼저 목록을 만들지 않았다). 자산 8건을 **돌연변이로 깨뜨려** 무엇이 잡는지 쟀다: | # | 우리 자산 | 돌연변이 | 무엇이 잡나 | @@ -276,26 +278,27 @@ sed -n '/^| # | 우리 자산/,/^$/p' docs/upstream-sync-approach.md \ > ★**CI 가 치는 것 중 로컬 DoD 에 없는 것이 «1건이라도» 생기면 이 결정을 다시 연다.** > ★★**«치는 것»은 «줄»만이 아니라 «매트릭스 차원»도 포함한다** — 그래서 축이 둘이다. +★★★**[2026-09-04 후속 — 이 두 축은 이제 «기계»가 센다. 손으로 세지 마라.]** +종전에는 여기 셸 두 토막이 있었고 ★**회차가 그것을 «돌려야» 했다**(C7⒜ 가 「그 축을 돌리는 것이 사람이다」로 고지한 그 구멍). +그 손 대조는 이 리니지에서 **다섯 번 실패**했다. ⇒ ★**같은 술어를 코드로 옮겼다**: + ```sh -cd "$(git rev-parse --show-toplevel)" -DOD=$(sed -n '/## Definition of Done/,/^- 착수·완료마다/p' CLAUDE.md) - -# ★축① — CI 의 `- run:` 줄 ∖ 로컬 DoD (0 이어야 한다) -LC_ALL=C /usr/bin/grep -E '^[[:space:]]+- run: ' .github/workflows/rust.yml | sed 's/^[[:space:]]*- run: //' \ -| while IFS= read -r c; do printf '%s' "$DOD" | LC_ALL=C /usr/bin/grep -qF -- "$c" || echo "MISSING-RUN: $c"; done \ -| /usr/bin/grep -c . - -# ★축② — CI 의 toolchain 매트릭스 ∖ 로컬 DoD (0 이어야 한다) -LC_ALL=C /usr/bin/grep -E '^[[:space:]]+rust: \[' .github/workflows/rust.yml \ -| sed 's/.*\[//; s/\].*//; s/, */\n/g' \ -| while IFS= read -r tc; do - [ "$tc" = stable ] && { printf '%s' "$DOD" | LC_ALL=C /usr/bin/grep -qE 'cargo (fmt|clippy|test)' || echo "MISSING-TC: $tc"; continue; } - printf '%s' "$DOD" | LC_ALL=C /usr/bin/grep -qF -- "cargo +$tc " || echo "MISSING-TC: $tc" - done | /usr/bin/grep -c . +python3 scripts/check-dod-ci-parity.py # rc=0 일치 · rc=1 대칭차 있음(어느 원소인지 찍는다) ``` -★**오늘의 값**(2026-09-04 정정 후): **축① `0`** · **축② `0`**. +★**CI 도 이것을 친다** — `rust.yml` job **`dod_parity`** · 로컬 DoD 블록에도 그 줄이 있다. +⇒ ★**셸 토막을 여기 다시 적지 마라 — 두 벌이 되면 다음 사람이 한쪽만 고친다.** 정본은 그 스크립트다. +★**정본 위치도 문서가 아니라 «스크립트 안»에 박혀 있다**(`CI_FILE`·`DOD_FILE`·`DOD_SECTION`). + +★**오늘의 값**(2026-09-04 · 검사기 착지 후): ★**축 A(명령) 대칭차 `0`** · ★**축 B(toolchain) 대칭차 `0`** +— 출력은 「OK 두 축 모두 대칭차 0 — 명령 6개 · toolchain 2개로 «둘 다 일치»」. ※착수 시엔 **축① 5 · 축② 1**(beta 가 DoD 에 없었다) — 축①은 원 회차가, 축②는 정정 회차가 0 으로 만들었다. +★★**검사기가 «못 보는 것»을 침묵시키지 않는다 — 그 자리에서 함께 찍는다**: +⒜**OS 축**(조건부 step) — 로컬 재현 불가라 CI 가 유일한 그물이다. +⒝★**두 축은 «독립»으로 비교한다 — 교차곱이 아니다.** CI 는 cargo 검사 4종을 stable·beta **둘 다** 치는데 +DoD 는 `clippy` 만 이중으로 친다. ★**이것은 결함이 아니라 2026-09-04 결정이 «고른 값»이다**(로컬에서 +`cargo +beta test` 는 두 번째 toolchain 전면 재빌드다 · lint 를 지는 축은 clippy 뿐이고 실측 gap 도 거기였다). +⇒ ★**교차곱까지 넓히는 것은 «버그 수정»이 아니라 «새 결정»이다.** ★★**축②를 왜 따로 두는가 — 축① 만으로는 «구조적으로» 못 본다.** 실측(정정 회차): @@ -316,6 +319,12 @@ LC_ALL=C /usr/bin/grep -E '^[[:space:]]+rust: \[' .github/workflows/rust.yml \ ⒜**DoD 블록 자체의 개악은 자동으로 안 잡힌다** — 누가 그 블록에서 한 줄을 지우면 축①이 **1** 이 되지만, ★**그 축을 «돌리는 것»이 사람이다.** 기계 강제(DoD ↔ `rust.yml` 대조 CI job)는 이 회차 범위 밖이고, ★**고칠 수 없으면 «고지»가 처방이다.** ⇒ ★**이 절을 읽는 회차는 「축①·축②를 «실제로 돌렸는가»」를 회신에 적어라.** +★★★**[2026-09-04 후속 — ⒜ 는 «닫혔다». 위 문장은 사료다.]** 그 「범위 밖」을 총괄이 판정해 +**`scripts/check-dod-ci-parity.py` + CI job `dod_parity`** 로 배선했다 ⇒ ★**DoD 블록에서 한 줄을 지우면 +그 자리에서 CI 가 red 다**(개악 대조 실측: DoD wasm32 줄 제거 → rc=1 · CI 에 `- run:` 1줄 추가 → rc=1 · +매트릭스에 nightly 추가 → rc=1 · DoD 의 `+beta` 줄 제거 → rc=1 · `rust-toolchain.toml` 신설 → rc=1). +⇒ ★**「돌렸는가」를 회신에 적는 관례는 이제 «불요»다** — 안 돌리면 CI 가 대신 말한다. +★**⒝(OS 축)는 그대로 열려 있다** — 검사기도 그것을 «제외»로 찍을 뿐 잡지 못한다. ⒝★**OS 축(macos·ubuntu·windows 3종)은 로컬에서 재현할 수 없다** — 그 차원은 ★**CI 가 «유일한» 그물**이고 대조 대상이 아니다. **결함이 아니라 «알고 두는» 값이다.** diff --git a/docs/worklog/2026-09-04-dod-ci-parity-lock.json b/docs/worklog/2026-09-04-dod-ci-parity-lock.json new file mode 100644 index 00000000..7cc66c05 --- /dev/null +++ b/docs/worklog/2026-09-04-dod-ci-parity-lock.json @@ -0,0 +1,56 @@ +{ + "schema": 1, + "date": "2026-09-04", + "title": "로컬 DoD ↔ CI 매트릭스 «기계 대조» 신설 — 다섯 번 실패한 사람 손 대조를 코드로 옮겼다", + "services": [ + "RustJava" + ], + "taskId": "rustjava-local-dod-vs-ci-matrix-mechanical-check", + "summary": "총괄 판정에 따라 로컬 DoD ↔ .github/workflows/rust.yml 기계 대조 1개를 신설했다: scripts/check-dod-ci-parity.py + CI job dod_parity. ★핵심 설계 제약을 먼저 지켰다 — 「낡은 문자열 스캐너」를 만들지 않았다. 게이트② 검수자가 실측으로 「F1 이 다섯 회차의 sweep 을 통과한 이유는 수가 아니라 «말»로 쓰인 낡은 주장이고 그것은 문자열 검사기로도 안 잡힌다」를 세웠기 때문이다. ⇒ 문서의 문장을 검사하지 않고, 문서가 틀리는 «원인»(두 집합이 갈린 것)을 잡는다: CLAUDE.md §Definition of Done 의 첫 코드블록과 rust.yml 을 각각 파싱해 대칭차를 낸다. 축은 둘 — A(명령 집합) · B(toolchain 집합 = strategy.matrix.rust ↔ cargo + 접두). 오늘 대칭차가 둘 다 0 이라 계약대로 경고가 아니라 rc=1(차단)로 배선했다. 검사기 자신의 줄을 CI job 과 DoD 블록 «양쪽»에 넣어 자기 존재를 자기가 강제한다(DoD 6줄 → 7줄). 정본 위치는 문서가 아니라 스크립트 안에 박았다(CI_FILE·DOD_FILE·DOD_SECTION) — 문서는 이 리니지에서 다섯 번 낡았다. 개악 5건 전건 red · 무개악 green 으로 공허하지 않음을 보였다. 그리고 검사기가 «못 보는 것»(조건부 step = OS 축 · 두 축을 교차곱으로 보지 않는 것)을 성공 출력에서도 함께 찍어 침묵하지 않는다.", + "changes": [ + "scripts/check-dod-ci-parity.py 신설(stdlib 전용 — 형제 락 check-worklog-json.py 와 같은 규율이고 CI 가 의존성을 설치하지 않는다). CLAUDE.md DoD 코드블록과 .github/workflows/rust.yml 을 각각 파싱해 축 A(명령 집합)·축 B(toolchain 집합)의 대칭차를 낸다. 성공에도 「명령 N개 · toolchain N개로 둘 다 일치」를 찍고(조용한 통과 금지), 못 보는 것(조건부 step)도 함께 찍는다. rust-toolchain.toml 이 생기면 맨 cargo = stable 매핑이 깨지므로 그 자체를 red 로 잡는 가드를 포함한다.", + ".github/workflows/rust.yml — job dod_parity 신설(단일 러너 · 6셀 매트릭스 아님). worklog_json 과 같은 층이다.", + "CLAUDE.md §Definition of Done — 블록에 python3 scripts/check-dod-ci-parity.py 추가(6줄 → 7줄). ★그리고 「이 여섯 줄」이라는 «수»를 문장에서 뺐다 — 세는 것은 검사기 몫이다(이 리니지가 그 수로 다섯 번 낡았다).", + "docs/upstream-sync-approach.md §4 — 재개 조건의 셸 두 토막을 지우고 스크립트 한 줄로 바꿨다(같은 술어가 두 벌이면 다음 사람이 한쪽만 고친다). C7⒜(「그 축을 돌리는 것이 사람이다」)에 «닫혔다» 표지 + 개악 5건 목록. ⒝(OS 축)는 그대로 열어 뒀다.", + "REPORT.md · STATE.md · approach.md 의 「DoD 6줄」 present-tense 3자리에 정정 표지 — ★7 로 갈아 끼우지 않고 «수를 문장에서 뺐다»." + ], + "deploy": { + "sha": "", + "urls": [] + }, + "verification": "착수 실측: origin/main 7b2dcc41(직전 회차 착지 직후) · behind 0 · 열린 PR 0. 정본 실측(계약 1⒜): 이 repo 에 DoD 를 실행하는 스크립트는 없다(scripts/ = check-worklog-json.py 뿐이었다) ⇒ DoD 정본 = CLAUDE.md §Definition of Done 의 첫 코드블록. 축 A 원소 6(cargo fmt --all -- --check · cargo clippy --all -- -D warnings · cargo clippy --workspace --exclude test-utils --target wasm32-unknown-unknown -- -D warnings · cargo test --all · python3 scripts/check-worklog-json.py · python3 scripts/check-dod-ci-parity.py) — CI 6 · DoD 6 · 대칭차 0. 축 B 원소 2(stable · beta) — 대칭차 0. 제외 1건(if: startsWith(matrix.os, 'windows') 조건부 셸 블록 = OS 축). ★개악 대조(각 회 원상 복구 후 재확인): ⓪무돌연변이 rc=0 · ⒜rust.yml 에 - run: cargo doc --no-deps 추가 rc=1(축 A) · ⒝DoD 에서 wasm32 줄 제거 rc=1(축 A) · ⒞매트릭스 [stable, beta, nightly] rc=1(축 B) · ⒟DoD 의 +beta 줄 제거 rc=1(축 B) · ⒠rust-toolchain.toml 신설 rc=1(toolchain 고정 가드). 복구 후 rc=0 재확인 + diff 로 두 파일 원상 동일 확인. DoD 7줄 전건 rc=0 · cargo test --all 554 passed / 0 failed / 1 ignored(baseline 554/0/1 동수 · 새 red 0) · .rs 변경 0 · allow 9곳 무접촉.", + "issues": [ + "★설계 제약을 먼저 읽고 지켰다 — 「낡은 문자열 스캐너」를 만들지 않았다. 그것을 만들었으면 F1 형태(수가 아니라 «말»로 쓰인 낡은 주장)는 여전히 샜을 것이고, 검사기가 있다는 사실이 오히려 안전 감각을 만들었을 것이다.", + "★★두 축을 «교차곱»으로 보지 않는다 — 이것은 결함이 아니라 2026-09-04 결정이 «고른 값»이다. CI 는 cargo 검사 4종을 stable·beta 둘 다 치는데 DoD 는 clippy 만 이중이라 fmt@beta·wasm32@beta·test@beta 3쌍이 로컬에 없다. 로컬 cargo +beta test 는 두 번째 toolchain 전면 재빌드이고, lint 를 지는 축은 clippy 뿐이며 실측 gap(allow 9곳 삭제 → stable 0 / beta rc=101 · 진단 6)도 거기였다. ⇒ 넓히는 것은 «버그 수정»이 아니라 «새 결정»이고 proposals[0] 으로 올린다. ★검사기가 이 한계를 docstring 과 문서에 명시해 숨기지 않는다.", + "★OS 축(macos·ubuntu·windows)은 여전히 CI 가 유일한 그물이다 — 조건부 step 은 축 A 에서 제외하되 출력에 «제외» 로 찍어 침묵시키지 않는다. C7⒝ 는 그대로 열려 있다.", + "★F1(워크로그 2026-09-04-sync-contract-stale-assets-decision.md:54 의 「CI 검사 «한 줄»」)을 이 회차에서 고치지 않았다 — 게이트② 검수자가 「다음에 그 워크로그를 만지는 회차가 §4 와 같은 표지를 :54 에 달면 닫힌다」로 처분했고 이 티켓은 그 회차가 아니다.", + "★이 검사기는 «자기 존재를 자기가 강제»한다(CI job + DoD 블록 양쪽에 자기 줄이 있어야 대칭차 0). 누가 CI 에서 dod_parity job 을 통째로 지우면 검사기 자신이 돌지 않으므로 그 삭제는 잡히지 않는다 — 그 층(워크플로 job 삭제)은 이 검사기의 범위 밖이고, 사람 리뷰가 지는 자리다.", + "머지하지 않았다 — 게이트②·③은 별 세션이다. ★게이트③은 등재 repo 라 merge_strategy: merge 필수.", + "★[게이트③ 집행] PR #26 을 --merge 로 착지시켰다(등재 repo — 스쿼시는 계보를 접는다). 동봉 «전» head 가 검수 핀 db30cf8c3547cf06d456bc5207f749fb5bda305a 과 일치함을 확인했고(git fetch 를 먼저 쳐 거짓 일치를 막았다), 이 원장 동봉으로 head 가 움직인 전/후 sha 는 머지 회신에 적었다. 착지 diff 8파일 · 배포 워크플로 0개 ⇒ 배포 0. 착지 후 origin/main..upstream/main = 0 유지 · 신설 job dod_parity success 확인." + ], + "proposals": [ + { + "title": "축 A × 축 B «교차곱»까지 넓힐지 판정하라 — 비용을 먼저 재고 정하라", + "plainSummary": "지금은 「어떤 검사를 하는가」와 「어떤 컴파일러 버전으로 하는가」를 따로 맞춘다. 원본 CI 는 그 둘을 곱해서 돌리는데, 로컬에서 그렇게 하려면 시간이 두 배로 든다.", + "userBenefit": "교차곱까지 맞추면 「로컬 green 인데 CI red」가 이 축에서 완전히 사라진다. 지금은 clippy 축만 막혀 있어 fmt·test 가 beta 에서만 깨지는 경우가 이론적으로 남는다.", + "why": "검사기가 두 축을 독립으로 비교하므로 fmt@beta·wasm32@beta·test@beta 3쌍이 로컬에 없다. 2026-09-04 결정은 그 비용(두 번째 toolchain 전면 재빌드)을 이유로 clippy 만 이중화했고, 실측 gap 도 clippy 축이었다. 그러나 그 판단은 «그때의 값»이고 재보지 않았다.", + "tradeoff": "⑴교차곱을 켜면 회차마다 cargo +beta test 가 붙는다 — 이 저장소 테스트는 554건이라 전면 재빌드 비용이 크다. ⑵켜지 않으면 beta 전용 fmt·test 회귀는 CI 에서만 드러난다(지금 상태). ⑶중간안으로 fmt 만 교차곱에 넣는 것도 가능하다(fmt 는 재빌드가 없다) — 그러면 3쌍 중 1쌍이 닫힌다.", + "effort": "S — 검사기의 축 비교를 교차곱으로 바꾸고 DoD 에 줄을 더한다. 판정 자체는 XS 이고 비용 측정이 대부분이다.", + "target": "scripts/check-dod-ci-parity.py · CLAUDE.md §Definition of Done" + }, + { + "title": "같은 파리티 락이 형제 repo(wie·qts)에도 필요한지 판정하라", + "plainSummary": "이 저장소에서 다섯 번 반복된 문제였다. 같은 구조(로컬 체크리스트 + CI 워크플로)를 가진 다른 저장소도 같은 방식으로 어긋날 수 있다.", + "userBenefit": "어긋남이 생긴 회차에서 바로 red 가 나므로, 문서가 조용히 낡아 다음 회차가 그것을 근거로 인용하는 연쇄를 끊는다.", + "why": "이 검사기의 정본 위치(CLAUDE.md DoD 코드블록 · 워크플로 파일)는 이 repo 에 맞춰 코드에 박혀 있다. 형제 repo 는 DoD 형태가 다를 수 있어 그대로 복사되지 않는다 — 먼저 «그 repo 의 DoD 정본이 무엇인가»를 실측해야 한다.", + "tradeoff": "⑴복사하면 각 repo 가 자기 파서를 갖게 되어 네 벌이 된다(이 저장소가 규탄한 «두 벌» 형태의 확대). ⑵공용 도구로 빼면 repo 마다 정본 위치가 달라 설정이 필요하고, 그 설정 파일이 다시 낡을 수 있다. ⑶아무것도 안 하면 형제 repo 는 사람 손 대조로 남는다 — 그것이 여기서 다섯 번 실패한 방식이다.", + "effort": "S — repo 당 정본 실측 + 판정. 구현은 별건.", + "target": "wie · qts · 총괄 발권 정책" + } + ], + "resolvedIssues": [ + "C7⒜ — 「DoD 블록 자체의 개악은 자동으로 안 잡힌다 · 그 축을 돌리는 것이 사람이다」(docs/upstream-sync-approach.md §4). 기계 강제가 배선되어 닫혔다. ⒝(OS 축)는 그대로 열려 있다." + ], + "adoptedProposals": [], + "declinedProposals": [] +} diff --git a/docs/worklog/2026-09-04-dod-ci-parity-lock.md b/docs/worklog/2026-09-04-dod-ci-parity-lock.md new file mode 100644 index 00000000..f889df21 --- /dev/null +++ b/docs/worklog/2026-09-04-dod-ci-parity-lock.md @@ -0,0 +1,97 @@ +# [2026-09-04] 로컬 DoD ↔ CI 매트릭스 «기계 대조» — 사람 손을 뺐다 + +**티켓**: `rustjava-local-dod-vs-ci-matrix-mechanical-check` +**성격**: 검사기 신설 + CI 배선 · 코드(`.rs`) 변경 **0** · 신설 1 · 수정 4 +**정본 위치**: ★**문서가 아니라 스크립트 안**(`scripts/check-dod-ci-parity.py` 의 `CI_FILE`·`DOD_FILE`·`DOD_SECTION`) + +--- + +## 1. 왜 — 사람 손 대조가 «다섯 번» 실패했다 + +`…-going-stale` 리니지는 초판 → fix → fix2 → fix3 으로 **5회차**를 돌았고, ★**매 회차가 「전건 동기」를 주장했고 +매번 «또 한 자리»가 나왔다**(1 → 2 → 3 → 4 → 5, 게이트②에서 **6건째**). 전부 같은 종류다 — +「로컬 DoD 가 CI 검사 몇 개를 재현하는가」라는 **수가 `rust.yml` 과 어긋난 채 문서에 굳었다**. + +★★**그런데 「낡은 문자열 스캐너」는 만들지 않았다 — 설계 제약이 그것을 금한다.** +게이트② 검수자가 실측으로 세운 것: ★**F1 이 다섯 회차의 sweep 을 통과한 이유는 「수가 아니라 «말»로 쓰인 +낡은 주장」이고 그것은 문자열 검사기로도 안 잡힌다.** ⇒ 문서의 **문장**을 검사하면 F1 형태는 여전히 샌다. +⇒ ★**대상은 «수»가 아니라 «사실»이다** — 두 집합을 각각 파싱해 대조하고, ★**문서가 틀리는 «원인»**을 잡는다. + +## 2. 두 집합 — ★**원소를 나열한다**(요약 수만 적지 않는다) + +### ⒜ 정본 위치 (계약 1 · 실측해서 적는다) + +| 축 | 정본 | 실측 근거 | +|---|---|---| +| 로컬 DoD | ★`CLAUDE.md` §Definition of Done 의 **첫 코드블록** | 이 repo 에 DoD 를 실행하는 **스크립트는 없다** — DoD 는 그 코드블록이 전부다(`scripts/` = `check-worklog-json.py` 뿐이었다) | +| CI | `.github/workflows/rust.yml` | job **2 → 3**(`rust_ci` 6셀 · `worklog_json` · ★신설 `dod_parity`) | + +### ⒝ 축 A — 명령 집합 (검사기 출력 그대로) + +| 명령 | CI | DoD | +|---|---|---| +| `cargo fmt --all -- --check` | y | y | +| `cargo clippy --all -- -D warnings` | y | y | +| `cargo clippy --workspace --exclude test-utils --target wasm32-unknown-unknown -- -D warnings` | y | y | +| `cargo test --all` | y | y | +| `python3 scripts/check-worklog-json.py` | y | y | +| ★`python3 scripts/check-dod-ci-parity.py` | y | y | + +★**대칭차 «0»** — CI 6 · DoD 6. +※`cargo +beta clippy …` 는 접두를 벗겨 `cargo clippy --all -- -D warnings` 로 정규화된다(축 B 로 간다). + +### ⒞ 축 B — toolchain 집합 + +| | 값 | 원천 | +|---|---|---| +| CI | `beta` · `stable` | `strategy.matrix.rust: [stable, beta]` | +| DoD | `beta` · `stable` | `cargo +beta …` 1줄 + 맨 `cargo` 5줄(★`rust-toolchain.toml` 부재 ⇒ 기본 = stable) | + +★**대칭차 «0»**. + +### ⒟ ★검사기가 «못 보는 것» — 침묵시키지 않고 «그 자리에서» 찍는다 + +- **조건부 step 1건** — `if: startsWith(matrix.os, 'windows')` / `<셸 블록> git config --global core.autocrlf false`. + ⇒ ★**OS 축은 로컬 재현 불가라 축 A 에서 제외**하되 **출력에 남긴다.** +- ★★**두 축은 «독립»으로 비교한다 — «교차곱»이 아니다.** CI 는 cargo 검사 4종을 stable·beta **둘 다** 치는데 + DoD 는 `clippy` 만 이중이다(= `fmt@beta`·`wasm32@beta`·`test@beta` 3쌍이 로컬에 없다). + ★**결함이 아니라 2026-09-04 결정이 «고른 값»이다** — 로컬 `cargo +beta test` 는 두 번째 toolchain 전면 + 재빌드이고, lint 를 지는 축은 clippy 뿐이며 실측 gap(`allow` 9곳 삭제 → stable 0 / beta rc=101 · 진단 6)도 + 거기였다. ⇒ ★**교차곱까지 넓히는 것은 «버그 수정»이 아니라 «새 결정»이다**(제안 `#p0`). + +## 3. 배선 — 계약 2 (★경고가 아니라 «막는다») + +지금 대칭차가 **0** 이므로 계약이 정한 대로 **rc=1(차단)** 으로 켰다: +- `.github/workflows/rust.yml` 에 job **`dod_parity`** 신설(단일 러너 — 6셀 매트릭스가 아니다) +- ★**DoD 블록에도 같은 줄을 넣었다** ⇒ ★**검사기가 자기 존재를 자기가 강제한다**(양쪽에 있어야 대칭차 0) +- ⇒ DoD 6줄 → **7줄**. ★**그리고 그 수를 문장에 적지 않는다** — 세는 것은 검사기 몫이다 + (이 리니지가 정확히 그 수로 다섯 번 낡았다). + +## 4. 개악 대조 — ★**5건 전건 red · 무개악 green** + +| # | 돌연변이 | rc | 검사기가 «무엇을» 말했나 | +|---|---|---|---| +| ⓪ | 없음 | **0** | `OK 두 축 모두 대칭차 0 — 명령 6개 · toolchain 2개로 «둘 다 일치»` | +| ⒜ | `rust.yml` 에 `- run: cargo doc --no-deps` 추가 | ★**1** | `★ CI 에만 있다 — DoD 에 이 줄을 넣어라: cargo doc --no-deps` | +| ⒝ | DoD 에서 **wasm32 줄 제거** | ★**1** | `★ CI 에만 있다 — DoD 에 이 줄을 넣어라: cargo clippy --workspace --exclude test-utils …` | +| ⒞ | 매트릭스 `[stable, beta, nightly]` | ★**1** | `★ CI 에만 있다 — DoD 에 \`cargo +nightly …\` 줄이 없다: nightly` (축 B) | +| ⒟ | DoD 에서 **`+beta` 줄 제거** | ★**1** | `★ CI 에만 있다 — DoD 에 \`cargo +beta …\` 줄이 없다: beta` (축 B) | +| ⒠ | `rust-toolchain.toml` 신설 | ★**1** | `★ … 맨 cargo 가 더는 «stable» 이 아니다 — split_toolchain() 을 함께 고쳐라` | + +★**⒜⒝ 는 티켓이 요구한 두 건이고, ⒞⒟ 는 축 B 가 «자기 자리에서만» 반응함을 보이며, ⒠ 는 «축 B 매핑의 전제»를 지킨다.** +★**전부 복구 후 재확인 rc=0** · `diff` 로 두 파일 원상 동일 확인. + +## 5. 문서 쪽 — ★**같은 술어를 두 벌로 두지 않았다** + +- `docs/upstream-sync-approach.md` §4 의 **셸 두 토막을 지우고** 스크립트 한 줄로 바꿨다 + (★두 벌이면 다음 사람이 한쪽만 고친다 — 이 저장소가 반복해 규탄한 형태). +- **C7⒜**(「DoD 블록 개악은 자동으로 안 잡힌다 — 그 축을 돌리는 것이 사람이다」)에 ★**«닫혔다» 표지**를 달았다. + ★**⒝(OS 축)는 그대로 열려 있다.** +- 「DoD **6줄**」이라 적힌 **present-tense 3자리**(`REPORT.md` · `STATE.md` · `approach.md`)에 정정 표지를 달았다 — + ★**이번엔 «7» 로 갈아 끼우지 않고 «수를 문장에서 뺐다»**(그래야 다음 회차에 또 낡지 않는다). + +## 6. 경계 + +★**「낡은 문자열」·「정정 표지」 스캐너 0**(설계 제약) · ★**F1(워크로그 `.md:54`) 무접촉**(그 회차가 아니다) · +★upstream 동기 무접촉(behind **0** · S9 없음) · ★`allow` 9곳 무접촉 · ★`.rs` 로직 변경 **0** · +머지 **0** · force-push **0** · `main` 직접 push **0** · 시크릿 출력 **0** · 맨 `grep` **0**. diff --git a/scripts/check-dod-ci-parity.py b/scripts/check-dod-ci-parity.py new file mode 100644 index 00000000..5ebeb1fa --- /dev/null +++ b/scripts/check-dod-ci-parity.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Lock: the local DoD command block must reproduce what CI actually runs. + +Why this lock exists — measured, not assumed. The `…-going-stale` lineage ran five +rounds (initial → fix3) and every single one claimed "fully synced"; every single one +left one more place where the *number of CI checks the local DoD reproduces* disagreed +with `.github/workflows/rust.yml`. Hand comparison failed five times in a row. + +So this does NOT scan prose for stale numbers — that was tried and rejected: the sixth +miss was a *word* ("CI 검사 «한 줄»"), not a number, and no string scanner catches that. +Instead it parses the two things themselves and prints the symmetric difference. Prose +goes stale because the two sets drift; this locks the sets. + +Two axes, mirroring the reopen condition in docs/upstream-sync-approach.md §4: + axis A — the command set (a `- run:` step in CI ↔ a line in the DoD block) + axis B — the toolchain set (`strategy.matrix.rust` ↔ `cargo +` prefixes in the DoD) + +The two axes are compared INDEPENDENTLY, not as a cross product. That is a deliberate +ceiling, stated so it cannot hide: CI runs all 4 cargo checks under both stable and beta, +while the DoD only doubles up `clippy` (`cargo +beta clippy`). Running `cargo +beta test` +locally means a full second-toolchain rebuild every round, and the 2026-09-04 decision +round weighed that and declined it — clippy is the only lint-bearing axis, and that is +where the measured gap was (9 `#[allow(clippy::double_must_use)]` deleted → stable 0, +beta rc=101 / 6 diagnostics). Widening to the cross product is a decision, not a bug fix. + +Also out of scope by the same decision: the OS axis (macos/ubuntu/windows). It cannot be +reproduced locally at all, so CI is its only net. Conditional (`if:`-guarded) steps are +therefore excluded from axis A — but they are PRINTED, never silently dropped. +""" + +import re +import sys +from pathlib import Path + +# ★ The canonical locations live here, in code — not in a doc. Docs in this lineage went +# stale five times; this file is what the CI job actually executes. +ROOT = Path(__file__).resolve().parents[1] +CI_FILE = ROOT / ".github" / "workflows" / "rust.yml" +DOD_FILE = ROOT / "CLAUDE.md" +DOD_SECTION = "## Definition of Done" + +# Bare `cargo` resolves to the default toolchain. That equals CI's `stable` cell only +# because this repo pins nothing; a rust-toolchain file would silently break the mapping. +TOOLCHAIN_PIN_FILES = ("rust-toolchain.toml", "rust-toolchain") +DEFAULT_TOOLCHAIN = "stable" + + +def norm(cmd): + return " ".join(cmd.split()) + + +def split_toolchain(cmd): + """`cargo +beta clippy …` -> ("beta", "cargo clippy …"); anything else -> (None, cmd).""" + m = re.match(r"^cargo\s+\+(\S+)\s+(.*)$", cmd) + if m: + return m.group(1), norm("cargo " + m.group(2)) + if cmd.startswith("cargo "): + return DEFAULT_TOOLCHAIN, cmd + return None, cmd + + +def parse_ci(text): + """-> (checks, conditional, toolchains) reading `- run:` steps out of the workflow. + + Hand-rolled rather than PyYAML: the sibling lock is stdlib-only and CI installs + nothing for it. The file is small and its shape is asserted below. + """ + lines = text.splitlines() + steps, cur, step_indent = [], None, None + toolchains = set() + + for raw in lines: + m = re.match(r"^(\s*)rust:\s*\[(.*)\]\s*$", raw) + if m: + toolchains |= {t.strip() for t in m.group(2).split(",") if t.strip()} + continue + m = re.match(r"^(\s*)-\s+(.*)$", raw) + if m and (step_indent is None or len(m.group(1)) == step_indent): + # a list item at steps depth starts a new step — but only inside `steps:` + if re.match(r"^(name|uses|run|if):", m.group(2)) or (cur is not None): + if re.match(r"^(name|uses|run|if):", m.group(2)): + step_indent = len(m.group(1)) + cur = [m.group(2)] + steps.append(cur) + continue + if cur is not None: + stripped = raw.strip() + if raw.strip() == "": + continue + indent = len(raw) - len(raw.lstrip()) + if step_indent is not None and indent <= step_indent and not raw.lstrip().startswith("-"): + # dedented out of the step list (next job/key) + cur, step_indent = None, None + continue + cur.append(stripped) + + checks, conditional = [], [] + for step in steps: + body = "\n".join(step) + if not re.search(r"^run:", body, re.M): + continue + run = re.search(r"^run:\s*(.*)$", body, re.M).group(1) + if run.startswith("|") or run.startswith(">"): + # block scalar: a multi-line shell snippet. Keep it identifiable but do not + # pretend it is a single command — every one of these so far is setup. + tail = [s for s in step if not re.match(r"^(name|uses|run|if|with):", s)] + run = "<셸 블록> " + (tail[0] if tail else "") + cond = re.search(r"^if:\s*(.*)$", body, re.M) + if cond: + conditional.append((norm(run), norm(cond.group(1)))) + else: + checks.append(norm(run)) + return checks, conditional, toolchains + + +def parse_dod(text): + """-> command lines of the first fenced block inside the DoD section.""" + start = text.index(DOD_SECTION) + block = re.search(r"```[a-z]*\n(.*?)\n\s*```", text[start:], re.S) + if not block: + sys.exit("FAIL: CLAUDE.md §Definition of Done 에 코드블록이 없다 — 정본이 사라졌다") + return [norm(l) for l in block.group(1).splitlines() if l.strip()] + + +def main(): + ci_cmds, ci_cond, ci_tcs = parse_ci(CI_FILE.read_text()) + dod_lines = parse_dod(DOD_FILE.read_text()) + + dod_cmds, dod_tcs = set(), set() + for line in dod_lines: + tc, cmd = split_toolchain(line) + dod_cmds.add(cmd) + if tc: + dod_tcs.add(tc) + + ci_set = set(ci_cmds) + problems = [] + + print("DOD-CI-PARITY 로컬 DoD ↔ .github/workflows/rust.yml") + print(f" 정본: {DOD_FILE.name} §Definition of Done 의 첫 코드블록 ↔ {CI_FILE.relative_to(ROOT)}") + + print(f"\n [축 A · 명령] CI {len(ci_set)}개 · DoD {len(dod_cmds)}개") + for c in sorted(ci_set | dod_cmds): + mark = " " if (c in ci_set and c in dod_cmds) else "★!" + print(f" {mark} {c} (CI={'y' if c in ci_set else 'n'} DoD={'y' if c in dod_cmds else 'n'})") + only_ci, only_dod = ci_set - dod_cmds, dod_cmds - ci_set + if only_ci or only_dod: + problems.append("축 A") + for c in sorted(only_ci): + print(f" ★ CI 에만 있다 — DoD 에 이 줄을 넣어라: {c}") + for c in sorted(only_dod): + print(f" ★ DoD 에만 있다 — CI 가 안 치는 것을 DoD 가 시킨다: {c}") + + print(f"\n [축 B · toolchain] CI {sorted(ci_tcs)} · DoD {sorted(dod_tcs)}") + only_ci_tc, only_dod_tc = ci_tcs - dod_tcs, dod_tcs - ci_tcs + if only_ci_tc or only_dod_tc: + problems.append("축 B") + for t in sorted(only_ci_tc): + print(f" ★ CI 에만 있다 — DoD 에 `cargo +{t} …` 줄이 없다: {t}") + for t in sorted(only_dod_tc): + print(f" ★ DoD 에만 있다 — CI 가 안 도는 toolchain 이다: {t}") + + # Never silent: conditional steps are excluded from axis A, so they get printed. + print(f"\n [제외] 조건부 step {len(ci_cond)}건 — OS 축은 로컬 재현 불가(설계상 CI 가 유일한 그물)") + for run, cond in ci_cond: + print(f" - if: {cond} run: {run[:70]}") + + pinned = [f for f in TOOLCHAIN_PIN_FILES if (ROOT / f).exists()] + if pinned: + problems.append("toolchain 고정") + print(f"\n ★ {pinned} 이 생겼다 — 맨 `cargo` 가 더는 «{DEFAULT_TOOLCHAIN}» 이 아니다.") + print(" 이 검사기의 축 B 매핑이 깨졌으니 split_toolchain() 을 함께 고쳐라.") + + if problems: + print(f"\nFAIL 대칭차 있음: {' · '.join(problems)}") + return 1 + # ★ Not silent on success either — the sibling lineage made "조용한 통과" a violation. + print(f"\nOK 두 축 모두 대칭차 0 — 명령 {len(ci_set)}개 · toolchain {len(ci_tcs)}개로 «둘 다 일치»") + return 0 + + +if __name__ == "__main__": + sys.exit(main())