diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9246f299..30aeeee4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -62,6 +62,17 @@ jobs: - uses: actions/checkout@v7 - run: python3 scripts/check-worklog-json.py + # a conflict resolution that drops one side's work leaves no other trace: no marker, mergeable + # CLEAN, suite green. fetch-depth 0 because the check needs the merge commits and their parents, + # which a shallow checkout does not have. One runner, not the matrix. + merge_drops: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - run: python3 scripts/check-merge-dropped-symbols.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: diff --git a/CLAUDE.md b/CLAUDE.md index 2777e357..d83ee6a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ python3 scripts/check-worklog-json.py python3 scripts/check-dod-ci-parity.py python3 scripts/check-named-exception-classes-are-loadable.py + python3 scripts/check-merge-dropped-symbols.py ``` ★★**이 블록은 이제 «기계가 지킨다» — `scripts/check-dod-ci-parity.py`(CI job `dod_parity`)가 이 코드블록과 `rust.yml` 을 «각각 파싱해» 대칭차를 낸다.** 어긋나면 그 자리에서 red 다. diff --git a/REPORT.md b/REPORT.md index fc12941a..67159623 100644 --- a/REPORT.md +++ b/REPORT.md @@ -23,6 +23,23 @@ - 검증: `cargo test --all` **rc=0** · `check-dod-ci-parity` **「명령 7개 · toolchain 2개」 rc=0** · `check-worklog-json` rc=0. - ★후속 추천: ⑴**리터럴이 «아닌» `exception(` 호출부를 세라**(S — 구멍의 크기를 아직 모른다) ⑵**`.unwrap()` → throw 전환**(M · 이 회차가 명시적으로 범위 밖으로 둔 것). 상세 = `docs/worklog/2026-09-18-named-exception-classes-are-loadable.md`. +## [2026-09-18] 충돌 해소가 «한쪽 부모의 정의»를 떨어뜨렸는지 센다 — ★**개악 시험이 내 검사기의 구멍을 잡았다**(rustjava-count-symbols-dropped-from-second-parent-on-resolution) +- 무엇을: 채택 제안 `2026-09-17-union-restores-silently-dropped-makeconcat#p0`(worklog json 기록). ★**제품 코드 0줄** — 검사기 1개 + 의도 규약 + 기존 `rust.yml` 에 잡 하나(**새 워크플로 파일 0**). +- ★**ⓑ 를 먼저 쟀다**(이 게이트가 티켓을 끝낼 수 있었다): `origin/main` 에 손실을 **재구성**해 기존 축 전수 → 생성기 **rc=0** · 단일결함 감사 **rc=0**(23) · `test_class_format` **21** · `test_fixture_pins` **3** · 파리티 **rc=0** · `git status` **0줄** · 충돌 마커 **0** ⇒ ★**아무것도 못 잡는다.** + ★★**왜 조용한지를 정확히 알아냈다** — 손실은 **떨어진 이름들이 서로 자기완결일 때만** 조용하고(자기완결이 아니면 `AttributeError` 로 시끄럽게 죽는다), ★**충돌의 한쪽을 통째로 택하는 것이 바로 그 자기완결 슬라이스를 떨어뜨리는 것**이다. +- ★**ⓐ 「3건」을 다시 세니 «4건»** — `LINKED`·`MAKECONCAT_DESCRIPTOR`·`def make_concat_call_site`·`method fieldref`(두 해소 커밋 각각). +- ★**ⓒ 정의의 범위를 실제 4건에서 거꾸로 정했다**: ★넷째 `fieldref` 가 **`class Pool` 안의 메서드**라 ★**「최상위」만 보면 4 중 3만 잡는다** ⇒ **한 단계 중첩까지** 본다. +- ★**ⓓ**: `rustjava` 는 등재 repo 라 `--merge` 착지(main 머지 **83/200**)지만 ★**겨냥하는 것은 «브랜치 내부 base 당김»**(사고 둘 다 그것 · 최근 **26건**)이라 **착지 방식과 무관**하다. 부모 1개 범위 → `0 merge(s)` **rc=0**. +- ★**의도/사고 분리 = 커밋 trailer** `Dropped-from-theirs: -- ` ⇒ ★**자기 커밋 하나만 면제**하므로 티켓이 경고한 「영구 목록이 검사기를 무력화한다」가 **구조적으로 불가능**하다. +- ★★**설계를 한 번 갈아엎었다**: 초판의 «트리 == 부모1 이면 `-s ours` 로 보고 건너뛴다» 구조 면제가 ★**개악 시험에서 theirs 를 통째로 버린 머지까지 삼켜 rc=0** 이 됐다 — 그게 이 검사기의 존재 이유인 실패다 ⇒ **면제를 «선언»(`*` 와일드카드)으로 교체**. ★양방향을 안 돌렸으면 **끈 채로 착지**했다. +- ★**합격선 양방향 전건**: ⒜개악 **rc=1 + 이름을 말한다** · ⒝정상 **rc=0** · ⒞trailer **rc=0**, 지우면 **rc=1** · ⓓ스쿼시 **rc=0** · ★실사고에서 **손으로 찾은 것과 같은 분할**(생성기 4 사고 + `.rs` 2 의도)을 기계가 낸다. +- ★★**[게이트② 승계] 필터가 너무 좁아 «둘째 사고»를 놓쳤다 — 넓혔다.** 초판은 「theirs 가 건드린 파일만」 봤고 그 근거 문장이 **거짓**이었다 — `514d5b08` 이 같은 넷을 떨어뜨렸는데 초판은 ★**파일 «4개»를 보고도 findings 0 · rc=0** 으로 통과시켰다(확장본은 같은 머지에서 **13개**를 보고 그 넷을 짚는다 · 세 판본에 `check()` 를 직접 불러 잰 값). ⇒ 술어를 **「theirs 가 바꾼 파일 ∪ 머지가 바꾼 파일」**로 넓혀 ★**두 사고 «둘 다» rc=1**. ★좁힌 채 두면 「없는 보장을 있다고 적는」 형태가 된다(#67 F1 과 같은 형태). +- ★**오탐을 세었다 — ★이 회차가 «직접» 돌린 값이다**(인용 아님 · 창 `38b0df38..8c7b473f` = 83머지): 초판 **8건/19정의** → ★**확장본 10건/26정의**. ★**늘어난 것은 26이 아니라 «7정의»**(`514d5b08` 6 + `37ea5a13` 1)이고, ★**7 중 진짜 소실 4 · 오탐 3**(아래). ★★**시간은 「느리지 않았다」가 아니라 «유의한 증가 없음»이다** — 같은 창에서 **초판만 3회 독립 실행**이 **229s/286.8s/585.3s(2.6배)** 로 흔들리는 반면 판본 차이는 **0.8s/머지**다(수 출처 = 게이트² 검수 실측 · ★이 회차는 다시 재지 않았다). ★wide 의 파일 집합이 narrow 의 **상위집합**이라 **더 싸질 수는 없다**. 종전 「4.4초/머지」 삭제는 **유지**한다. +- ★**trailer 이름은 «출력에서 복사»해야 한다** — `method fieldref` 는 통하고 `Pool.fieldref` 는 **안 통한다**. ★초판 docstring 의 유일한 예시가 **안 통하는 쪽**이었다(게이트② 지적) ⇒ 예시를 고치고 **「글자 그대로 같아야 한다」를 계약에 박았다**. ※당시 전 이력 trailer **0건**이라 깨진 등재는 없다. ★CI 는 `origin/main..HEAD` 만 보므로 **과거 10건이 main 을 red 로 만들지 않는다** — ★**다만 그것을 «대가가 작다»의 근거로 쓰지 마라**: 새 대가는 정확히 그 PR 범위(다음 base 당김)에서 난다(아래 「잃는 것」). +- ★**잃는 것**: ★**이름은 남고 본문이 비어진 경우는 못 잡는다** · 리네임·이동을 소실과 **구별 못 한다** · ★**trailer 는 우회로다**(사유 필수 + 커밋에 남는 것이 유일한 브레이크 — 더 조이지 않은 것이 판단이다) · ★★**새 red 계급 — main 에도 있는 정의를 지우거나 개명한 브랜치는 «다음 base 당김»에서 red 가 되고, 평범한 리팩터가 trailer 를 달아야 한다**(실례 `37ea5a13` — `at_most_one_bootstrap_methods_attribute` 를 일반화해 치운 브랜치가 base 를 당기자 그 이름이 «떨어졌다»로 잡혔다. ★늘어난 7정의 중 **3이 이 계급**이다) · DoD 블록에 들어가 **로컬 실행에도** 붙는다. +- 검증: `cargo test --all` **583 passed / 0 failed / 1 ignored**(불변 — 제품 코드 무접촉) · `check-dod-ci-parity` → **「OK 두 축 모두 대칭차 0 — 명령 7개 · toolchain 2개로 «둘 다 일치»」**(★CI 잡을 늘렸으므로 DoD 블록도 같이 고쳤다). +- ★후속 추천: ⑴소급 8건 중 upstream 동기(S5~S8) 가 떨어뜨린 `decode_str`·`encode_str` 이 «발견인가 수용된 판단인가»를 그 리니지가 판정(S) — 상세 = `docs/worklog/2026-09-18-merge-dropped-symbols-check.md`. + ## [2026-09-18] 「어느 bootstrap argument 가 왜 나빴나」 — ★**대전제 ⓒ 에서 끝난다: 그 일을 하는 축이 이미 떠 있다**(rustjava-adopt-loadable-bootstrap-arguments-diagnostic) - 무엇을: 채택 제안 `2026-09-17-loadable-bootstrap-arguments#p0` 의 처분(worklog json `adoptedProposals` 기록). ★**코드 0행** — `classfile/src/{error,validation}.rs` **무접촉**. - ★**제안의 전제는 참이다 — CLI 로 돌려서 봤다**(`main` @ `8c7b473f`): 서로 다른 세 규칙(`LdcDynamicBSMArgPastEnd` 나쁜 argument · `LdcDynamicDuplicateBSM` 중복 속성 · `LdcDynamicOldMajor` 버전 게이트)이 ★**글자 하나 다르지 않은 `java.lang.ClassFormatError: Invalid class file`** 를 낸다. diff --git a/STATE.md b/STATE.md index b80cecde..5e4beb94 100644 --- a/STATE.md +++ b/STATE.md @@ -22,6 +22,14 @@ ★**못 보는 것**: **런타임 조립 이름 안 보임**(바닥이지 증명 아님) · `exception(` 만 · 초기화 실패 통과. ★**잃는 것**: DoD **6→7**(~1초 · 초판 32.7초는 `target/` 가지치기로 해소) · ★`.unwrap()` 무접촉(전환은 범위 밖). ★`--all` rc=0 · `dod-ci-parity` **명령 7개** rc=0. +- [rustjava-count-symbols-dropped-from-second-parent-on-resolution] ★★**충돌 해소가 떨어뜨린 정의를 센다 — 그리고 개악 시험이 «내 검사기»의 구멍을 잡았다.** 채택 제안 `2026-09-17-union-restores-silently-dropped-makeconcat#p0`. ★**제품 코드 0줄**(검사기 + 규약 + 기존 워크플로 잡 1). + ★**ⓑ 를 먼저 쟀다** — 손실을 재구성해 기존 축 전수(생성기·단일결함 감사·두 스위트·파리티·`git status`·마커) ⇒ ★**전부 green = 아무것도 못 잡는다.** ★조용한 조건은 「떨어진 이름들이 **자기완결**일 때」이고, 한쪽을 통째로 택하는 것이 정확히 그것이다. + ★**ⓐ 3건 → «4건»** · ★**ⓒ** 넷째가 **클래스 «안»의 메서드**라 「최상위」만 보면 **4 중 3**만 잡는다 ⇒ 한 단계 중첩까지. + ★★**설계 교체**: 「트리==부모1 ⇒ `-s ours` 면제」가 **개악을 삼켜 rc=0** 이 됐다(그게 존재 이유인 실패다) ⇒ **선언(`*` trailer)으로 교체**. + ★양방향 전건: 개악 **rc=1 + 이름** · 정상 **rc=0** · trailer **rc=0**/지우면 **rc=1** · 스쿼시 **rc=0** · 실사고에서 **손 분할과 동일**(4 사고 + 2 의도). + ★★**[게이트② 승계] 필터가 좁아 «둘째 사고»(`514d5b08`)를 놓쳤다** — 근거 문장이 거짓이었다 — 초판은 **파일 4개를 보고도** findings 0 · rc=0(확장본은 같은 머지에서 **13개**) ⇒ **「theirs 가 바꾼 파일 ∪ 머지가 바꾼 파일」**로 넓혀 ★**둘 다 rc=1**. ★docstring 의 유일한 예시가 **면제되지 않는 형식**이었다 ⇒ 고치고 「출력에서 복사하라」를 계약에 박았다(당시 trailer 0건). + ★**오탐 실측 — 이 회차가 직접 돌렸다**(창 `38b0df38..8c7b473f` 83머지): 초판 **8건/19정의** → 확장본 **10건/26정의**. ★늘어난 것은 **7정의**(`514d5b08` 6 + `37ea5a13` 1)이고 ★**진짜 4 · 오탐 3**. ★**시간은 «유의한 증가 없음»이다**(판본 차 ≈0.8s/머지 ↔ 같은 판본 재실행 편차 **2.6배** · 수 출처 = 게이트② 검수 실측 · ★재측 안 함). ★「느리지 않았다」는 틀렸다 — wide 는 narrow 의 상위집합이라 더 싸질 수 없다. ★**잃는 것**: 본문만 빈 경우·리네임을 **못 가른다** · trailer 는 **우회로**이고 **이름이 글자 그대로 대조**된다 · ★★**새 red 계급 — main 에도 있는 정의를 지운 브랜치는 다음 base 당김에서 red 가 된다**(실례 `37ea5a13` · 7 중 3). + ★`--all` **583/0/1**(불변) · 파리티 **명령 7개 일치**(DoD 블록 동기). - [rustjava-adopt-loadable-bootstrap-arguments-diagnostic] ★★**대전제 ⓒ 에서 끝났다 — 그 일을 하는 축(PR #67)이 이미 떠 있다.** 채택 제안 `2026-09-17-loadable-bootstrap-arguments#p0`(worklog json 기록). ★**코드 0행.** ★**전제는 참**(CLI 실행: 세 규칙이 전부 `ClassFormatError: Invalid class file` 동일 문면) — 그러나 ★**편집 영역은 전부 겹친다**(전달된 값은 **3 중 1** — 아래): #67 이 제안 `target` 두 파일을 고치고, 이 술어에 **이미 사유를 주며**, 밋밋한 문면 **두 자리**를 둘 다 고쳤다. ★제안 `tradeoff` 자신이 「두 번 하지 말고 함께 하라」고 적었다. ★**남는 잔여는 좁다** — `&'static str` 이라 **인덱스를 못 담는다** ⇒ 「기대」 달성 · 「인덱스·실제」 미달 ⇒ ★**새 카드를 냈다**(★**M** — 초판 `S` 에서 **실측 후 올렸다**: 잔여도 **같은 3층**을 건너 `target` **5파일/4크레이트**(`classfile`·`jvm-bytecode`·`RustJava`·`test-utils` — ★층 3 ↔ 크레이트 4: 경계 층이 두 크레이트에 걸친다) · `InvalidFormat` 을 넓히면 생성 17 + 매치 11 이라 **새 variant** 를 고르고 **두 갈래의 대가**를 적었다). diff --git a/docs/worklog/2026-09-18-merge-dropped-symbols-check.json b/docs/worklog/2026-09-18-merge-dropped-symbols-check.json new file mode 100644 index 00000000..209f8a66 --- /dev/null +++ b/docs/worklog/2026-09-18-merge-dropped-symbols-check.json @@ -0,0 +1,34 @@ +{ + "date": "2026-09-18", + "taskId": "rustjava-count-symbols-dropped-from-second-parent-on-resolution", + "summary": "A check that reports definitions present in a merge's second parent and absent from its result, wired into the existing rust.yml. The hard part was not counting but separating accident from intent, which is recorded as a per-commit trailer rather than a file so it cannot outlive the merge it excuses. The first design had a structural exemption for merges whose tree equals their first parent's; the mandated mutation test showed that exemption also swallowed the exact failure the check exists for, so it was removed in favour of an explicit wildcard trailer.", + "changes": [ + "scripts/check-merge-dropped-symbols.py: the check, with the intent trailer `Dropped-from-theirs: -- ` and a `*` wildcard for merges that keep our tree on purpose. The file filter covers what the merged-in branch changed and what the merge changed; the trailer name is matched literally against the check's printed form", + ".github/workflows/rust.yml: a merge_drops job on the existing file (no new workflow), fetch-depth 0 because the check needs merge parents", + "CLAUDE.md: the DoD block gains the same command — the parity lock requires CI and DoD to match, and it now reports 7 commands on both sides" + ], + "verification": [ + "PRECONDITION b, the gate that could have ended this ticket: reconstructed the loss on origin/main and ran every existing axis against it. Dropping a self-consistent slice (recipe_arity_call_site + RECIPE_ARITY + its write loop) leaves the generator at rc=0, audit-fixture-single-defect.py at rc=0 (23 checks), test_class_format 21 passed, test_fixture_pins 3 passed, check-dod-ci-parity rc=0, git status empty, zero conflict markers. Nothing catches it, so the gate does not fire", + "the same experiment with a NON-self-consistent slice does go red, but as an AttributeError crash rather than a diagnosis — which is why the silent case is the one that matters and had to be measured separately", + "PRECONDITION a, recount: the two resolutions e53b2142 and 514d5b08 each dropped 4 definitions, not the 3 the proposal states — LINKED, MAKECONCAT_DESCRIPTOR, def make_concat_call_site, method fieldref", + "MUTATION (a): a synthetic merge that discarded the other side's new function is rc=1 and names it — 'def kept_by_theirs — in the merged-in branch, not in the result'", + "NORMAL (b): the same merge resolved as a union is rc=0", + "INTENT (c): adding `Dropped-from-theirs: def kept_by_theirs -- superseded ...` makes it rc=0; removing the trailer returns it to rc=1; the `*` wildcard form is rc=0", + "SQUASH (d): a one-parent commit range reports '0 merge(s)' and rc=0", + "run against the real incident, the check reproduces by machine the same split the -fix3 round found by hand: 4 accidental drops in the generator plus 2 intentional test-function removals in tests/test_class_format.rs", + "cargo test --all: 583 passed / 0 failed / 1 ignored, unchanged — no product code touched", + "check-dod-ci-parity: OK, 7 commands and 2 toolchains matching on both sides after the wiring", + "GATE 2 found the filter too narrow and this round widened it. The original looked only at files the merged-in branch changed, on the stated grounds that 'only files the merged-in branch actually changed can have lost its work' — which is false, and the repo's own second incident proves it: 514d5b08 dropped the same four definitions as e53b2142, and the narrow filter examined 4 files, found 0 and returned rc=0 (the wide filter examines 13 on the same merge). It was not blind: it read four files and none of them was where the loss landed. The predicate is now every file that either the merged-in branch or the merge itself changed, and both incidents report rc=1 with the same six names.", + "The docstring's only worked example did not work. It wrote `Dropped-from-theirs: Pool.fieldref` while the check prints `method fieldref` and matches literally, so anyone following the example got no exemption. Verified both ways on a synthetic merge: the name copied from the output exempts (rc=0), the prettier form does not (rc=1). The example now uses the printed form and the docstring states that the name must be copied from the check's own output character for character." + ], + "issues": [ + "FALSE POSITIVE RATE, measured by this round by running both versions over one window of 83 merges (38b0df38..8c7b473f, the last 200 commits of origin/main at the time): the check as first written flags 8 merges / 19 definitions; the widened file filter flags 10 / 26. An earlier draft of this file said 45; that number was wrong and never came out of this implementation. The narrow version reproducing 19 exactly, in the same run, is what makes the measurement trustworthy. The seven extra definitions come from two merges, 514d5b08 (six) and 37ea5a13 (one), and they split four to three: four are the second of the two real incidents, restored later and present in the tree today; three are a branch having removed a definition main still had (two tests deleted on purpose by 0f517567, one predicate generalised by 1da1379e). So the honest rate is 4 of 7 found and 3 of 7 false, not 6 and 1. On time, the honest statement is that there is no significant increase, not that it is no slower: three independent runs of the narrow version over this window span 229s/286.8s/585.3s, a factor of 2.6, while the difference between versions in one back-to-back run was 70s over 83 merges, about 0.8s per merge (all four numbers come from gate-2 measurements; this round deliberately did not re-run them, because re-running produces a fourth different number and that is the point). The wide file set is a superset of the narrow one -- 4 files versus 13 on 514d5b08 -- so it cannot be structurally cheaper. The cost of widening is reading, not waiting: a pull request carries 0-2 internal merges. A pull request usually contains 0-2 internal merges. CI only walks origin/main..HEAD, so historical merges are never re-examined and do not turn main red -- but that is not a reason to call the cost small, because the new cost lands in exactly that range, on the next base pull of an open branch.", + "WHAT IT CANNOT SEE: a definition whose name survives while its body is emptied or gutted. The check compares name sets, so 'still there but no longer does anything' passes. It also cannot tell a genuine rename from a drop-plus-add, and a move between files reads as a drop in the old file.", + "THE TRAILER IS A BYPASS, and deliberately so: anyone in a hurry can write one and move on. The only brake is that a reason is required by the format and the trailer is visible in the merge commit. That is weaker than a gate and stronger than nothing; making it harder was judged over-design for a check with no track record yet.", + "Two of the flagged historical merges are the known incident. The rest are upstream-sync merges (S5-S8) and one `-s ours` ancestry record. The sync ones genuinely dropped upstream methods — decode_str and encode_str are absent from the whole result tree, not moved, which was checked — so whether they are findings or accepted judgements is for the sync lineage to say, not this round.", + "The exemption name is matched literally against the check's own output, which is a sharp edge: `method fieldref` works and `Pool.fieldref` does not. The docstring now says so and its example uses the printed form, but nothing verifies a trailer's name is well-formed — a typo still reads as 'not exempted' rather than as 'you spelled it wrong'. There were 0 trailers anywhere in the repo's 1,286 commits when this changed, so nothing existing broke." + ], + "adoptedProposals": [ + "2026-09-17-union-restores-silently-dropped-makeconcat#p0" + ] +} diff --git a/docs/worklog/2026-09-18-merge-dropped-symbols-check.md b/docs/worklog/2026-09-18-merge-dropped-symbols-check.md new file mode 100644 index 00000000..5c14fb99 --- /dev/null +++ b/docs/worklog/2026-09-18-merge-dropped-symbols-check.md @@ -0,0 +1,160 @@ +# 2026-09-18 — 충돌 해소가 «한쪽 부모의 정의»를 떨어뜨렸는지 센다 (rustjava-count-symbols-dropped-from-second-parent-on-resolution) + +채택 제안 `2026-09-17-union-restores-silently-dropped-makeconcat#p0`. ★**제품 코드 0줄** — 검사기 1개 + 의도 규약 + 기존 워크플로에 잡 하나. + +## ⓑ 먼저 — 이미 덮는 축이 «없다»는 것을 실행으로 확인했다 + +★**이 게이트가 이 티켓을 끝낼 수 있었으므로 먼저 쟀다.** `origin/main` 에 손실을 **재구성**하고 기존 축을 전부 돌렸다: + +| 축 | 손실 트리에서 | +|---|---| +| 생성기 실행 | **rc=0** | +| `audit-fixture-single-defect.py` | **rc=0**(검사 18 · 무결함 5 · 합계 23) | +| `cargo test --test test_class_format` | **21 passed** | +| `cargo test --test test_fixture_pins` | **3 passed** | +| `check-dod-ci-parity` | **rc=0** | +| `git status -- test-data/indy` | **0줄** | +| 충돌 마커 | **0** | + +⇒ ★**아무것도 잡지 못한다.** 게이트는 발화하지 않는다. + +★★**왜 조용한지를 «정확히» 알아냈다 — 이것이 설계를 정했다**: 손실은 **떨어진 이름들이 서로 자기완결일 때만** 조용하다. +자기완결이 아닌 슬라이스를 떨어뜨리면 `AttributeError` 로 **시끄럽게 죽는다**(그것도 쟀다). +그리고 ★**충돌의 한쪽을 통째로 택하는 것이 바로 «자기완결 슬라이스를 떨어뜨리는 것»**이다. + +## ⓐ 「3건」을 다시 셌다 — ★**4건이다** + +두 해소 커밋 각각에서 base·ours·theirs 의 정의 집합을 세고 `theirs − 결과` 를 냈다: + +``` +e53b2142 (merge-base 4b89f18a) base 14 · ours 20 · theirs 18 · 결과 20 → 떨어진 것 4 +514d5b08 (merge-base 42e99a7e) base 18 · ours 20 · theirs 18 · 결과 20 → 떨어진 것 4 + const LINKED · const MAKECONCAT_DESCRIPTOR · def make_concat_call_site · method fieldref +``` +제안은 **three** 라 적었다. ★**내 정의로는 4**이고, 넷째는 `MAKECONCAT_DESCRIPTOR` 다. ※쓰기 루프는 `if __name__` 안이라 «정의»가 아니어서 안 센다 — 그 한계는 아래에 적는다. + +## ⓒ 「최상위 정의」를 무엇으로 볼 것인가 — ★실제 4건에서 거꾸로 정했다 + +| 떨어진 것 | 계급 | +|---|---| +| `LINKED` · `MAKECONCAT_DESCRIPTOR` | 모듈 수준 대문자 대입 | +| `make_concat_call_site` | 모듈 수준 `def` | +| ★`fieldref` | ★**`class Pool` «안»의 메서드**(들여쓰기 4) | + +⇒ ★★**「최상위」만 보면 4건 중 3건만 잡는다.** 그래서 **한 단계 중첩(메서드)까지** 본다. Rust 도 같은 모양으로 넣었다(`fn`·`type`·`const`·`impl` 안 메서드). + +## ⓓ 스쿼시와의 상호작용 + +`rustjava` 는 `contracts/upstream-sync-repos.conf` **등재**라 **`--merge` 로 착지**한다 ⇒ main 에 **부모2가 있다**(최근 200 커밋 중 머지 **83**). +★**그러나 이 검사기가 겨냥하는 것은 착지가 아니라 «브랜치 내부 base 당김 머지»**이고(사고 둘 다 그것이었다 · 최근 83 중 **26건**), +그것은 **착지 방식과 무관하게** 존재한다. ⇒ **PR 시점에** 검사하므로 스쿼시 repo 에서도 같은 값을 한다. +실측: 부모 1개짜리 범위 → `0 merge(s)` · **rc=0**(조용히 통과하고, 그것이 문서화된 동작이다). + +## 의도와 사고를 가르는 목록 — ★**커밋 trailer 다** + +``` +Dropped-from-theirs: Pool.fieldref -- superseded by the new pool builder, see +Dropped-from-theirs: * -- recorded as a merged ancestor only, our tree is authoritative +``` + +- **어디에**: 그 **머지 커밋 메시지**에. +- **누가 언제 지우는가**: ★**지울 필요가 없다** — trailer 는 **자기 커밋 하나만** 면제한다. + ★티켓이 경고한 「영구 목록은 시간이 지나면 검사기를 무력화한다」가 **구조적으로 불가능**하다(파일이면 항목이 쌓여 검사기를 끈다). +- **왜**: `--` 뒤 사유가 **형식상 필수**다(없으면 매치되지 않아 면제되지 않는다). + +## ★★설계를 한 번 갈아엎었다 — 개악 시험이 «내 검사기»의 구멍을 잡았다 + +초판에는 **구조적 면제**가 있었다: 「결과 트리 == 부모1 트리면 `-s ours` 이니 건너뛴다」. +★**개악 시험이 그것을 무너뜨렸다** — theirs 를 통째로 버린 합성 머지도 **트리가 부모1과 같아서** +★**면제에 삼켜져 rc=0 이 됐다.** 그런데 그게 **이 검사기가 존재하는 이유인 바로 그 실패**다. +⇒ ★**구조적 면제를 걷어내고 «선언»(`*` 와일드카드)으로 바꿨다.** `-s ours` 도 이제 **말해야** 한다. +★이 교체가 이 회차에서 가장 값한 측정이다 — 양방향을 안 돌렸으면 **끈 채로 착지**했다. + +## 합격선 — 양방향 전건 + +| 축 | 결과 | +|---|---| +| ⒜ theirs 정의를 버린 해소 | ★**rc=1** · `def kept_by_theirs — in the merged-in branch, not in the result` (**이름을 말한다**) | +| ⒝ 아무것도 안 떨어뜨린 합집합 해소 | **rc=0** | +| ⒞ trailer 등재 | **rc=0** · ★**지우면 다시 rc=1** · `*` 와일드카드도 **rc=0** | +| ⓓ 스쿼시(부모 1개) | `0 merge(s)` · **rc=0** | +| 실사고 재현 | ★**손으로 찾은 것과 같은 분할을 기계가 낸다** — 생성기 **4건(사고)** + `.rs` **2건(의도)** | + +## ★★게이트② 가 «필터가 너무 좁다»를 잡았다 — 넓혔다 + +초판은 **theirs 가 건드린 파일만** 봤고, 그 근거로 「`Only files the merged-in branch actually changed can have +lost its work`」라 적었다. ★**그 문장이 거짓이고, 이 repo 자신의 «둘째» 사고가 반증한다**: +`514d5b08` 은 `e53b2142` 와 **같은 넷**을 떨어뜨렸는데 초판 검사기는 ★**파일 «4개»를 examine 하고도 findings 0 · rc=0** 으로 통과시켰다(확장본은 같은 머지에서 **13개**를 보고 그 넷을 짚는다). +⇒ ★**해소가 «상대가 건드리지도 않은» 파일을 되돌리는 것** — 「A 충돌 풀다가 B 를 되돌려 놨다」 — 이 검사기의 +**존재 이유 그대로의 실패**인데 못 봤다. + +★**술어를 「theirs 가 바꾼 파일 ∪ 머지가 바꾼 파일」로 넓혔다.** 결과: +| 머지 | 초판 | 확장본 | +|---|---|---| +| `e53b2142` | rc=1 · 6정의 | rc=1 · 6정의 | +| ★`514d5b08` | ★**rc=0**(놓침) | ★**rc=1 · 6정의**(같은 넷 + 의도 2) | + +★**왜 «좁힌 채 두지» 않았나**: 좁은 필터를 정당화하던 문장이 **거짓**이므로, 그대로 두면 +★**「없는 보장을 있다고 적는」 형태**가 된다 — 이 리니지가 `#67` 의 F1 에서 정확히 그것으로 반려됐다. + +## ⒟ 오탐 — ★**세었다**(「적을 것이다」로 적지 않는다) + +★★**이 회차가 «직접» 두 판본을 같은 창에 돌렸다**(인용 아님 · 창 `38b0df38..8c7b473f` = **83머지**): +초판(`ef6ab75e`) **8건 red · 정의 19개** ↔ ★**확장본(이 PR) 10건 red · 정의 26개**. +★**앵커** — 같은 스크립트·같은 창에서 초판이 **19 를 글자 그대로** 낸다(측정기가 옳다는 증거). +★**늘어난 것은 «26»이 아니라 «7정의»다** — `514d5b08` **6** + `37ea5a13` **1**. 그게 넓힘의 실제 크기다. +★★**그 7의 성격을 하나씩 판정했다 — 진짜 소실 «4» · 오탐 «3»**(아래 표). 종전 판까지 적혀 있던 **45** 는 **틀린 수**였다. +★★**시간 — 「느리지 않았다」로 적지 않는다. 측정 노이즈가 판본 차이보다 «크다».** +같은 창에서 초판을 **독립 3회** 돌린 값이 **229s / 286.8s / 585.3s** 로 ★**2.6배**까지 벌어진다 +(229s 는 게이트² fix2 검수자 실측 · 286.8s 는 그 앞 검수 · 585.3s 는 이 리니지 fix2 회차 · ★**이 회차는 다시 재지 않았다** — +재면 네 번째 다른 수가 나올 뿐이고 그것이 이 지적의 요지다). +한편 판본 차이는 **연속 실행 한 회차에서 70s / 83머지 ≈ 0.8s/머지**였다(같은 검수자 실측 · narrow 229s ↔ wide 299s). +⇒ ★**「유의한 비용 증가 없음」이 맞는 문장이고, 「비용 없음」은 아니다.** ★그리고 wide 의 파일 집합은 narrow 의 **상위집합**이라 +(`514d5b08` 에서 4 → 13) ★**구조적으로 더 싸질 수 없다** — 「느리지 않았다」는 방향부터 틀렸다. +종전 「4.4초/머지」를 지운 것은 **옳다**(재현 안 되는 수였다) — ★다만 그 자리에 **새 단정**을 넣은 것이 이번 반려 사유였다. +★**대가는 시간이 아니라 «읽을 것»이다**: PR 하나의 내부 머지는 보통 **0~2건**이다. +★**CI 는 `origin/main..HEAD`(= PR 범위)만 본다** — 과거 10건이 main 을 red 로 만들지는 않는다. +★★**그러나 그것을 «대가가 작다»의 근거로 쓰지 마라** — 새 대가는 정확히 그 PR 범위에서, 다음 base 당김에 난다(아래 「잃는 것」). + +| 늘어난 정의 | 머지 | 판정 | 근거(내 실측) | +|---|---|---|---| +| `make_indy_fixtures.py: LINKED` · `MAKECONCAT_DESCRIPTOR` · `def make_concat_call_site` · `method fieldref` | `514d5b08` | ★**진짜 소실 4** | 네 이름 모두 **현재 main 에 실재**(`-p2-fix3` 회차가 되살렸다) ⇒ 머지가 떨어뜨린 것이 맞다 | +| `test_class_format.rs: fn test_lambda_class_reports_unsupported_feature_not_malformed` · `fn test_only_the_string_concat_bootstrap_is_linked` | `514d5b08` | ★**오탐 2** | `git log -S`: 브랜치 커밋 **`0f517567`**(`…-p2` LambdaMetafactory 링크)이 **의도적으로** 지웠다 — 링크가 되니 그 두 단정이 거짓이 됐다. 현재 main 에도 **없다** | +| `validation.rs: fn at_most_one_bootstrap_methods_attribute` | `37ea5a13` | ★**오탐 1** | `git log -S`: `f2c83174` 도입 → 브랜치 **`1da1379e`** 가 `at_most_one_of_each_single_class_attribute` 로 **일반화해 치웠다**. 일반화판은 현재 main 에 **실재** | + +⇒ ★**정의 기준 4/7 이 발견 · 3/7 이 오탐.** ★**검수 회신이 제안한 「6/7 발견 · 1/7 오탐」은 내 실측과 다르다** — +검수자는 `37ea5a13` 1건만 표본으로 봤고, ★**`514d5b08` 안의 «테스트 2건»이 같은 계급**이라는 것은 전수로 봐야 나온다. +⇒ PR 하나에 내부 머지는 보통 **0~2건**이라 실행 비용은 여전히 작다 — ★**큰 것은 «오탐 비율»이다.** + +★**초판이 무는 8건의 성격을 확인했다**(추측하지 않았다 · 위 표의 «늘어난 2건»과 별개다): 2건은 **알려진 사고** · 1건은 `-s ours` 조상 기록 · +나머지는 **upstream 동기(S5~S8)** 인데, ★`decode_str`·`encode_str` 은 **결과 트리 어디에도 없다**(이동이 아니라 실제 소실 — 직접 확인). +⇒ ★**그것이 «발견»인지 «수용된 판단»인지는 그 리니지가 답할 몫**이고 이 회차가 정하지 않는다. + +## ★잃는 것 — 「없다」로 적지 않는다 + +- ★**이름은 남고 «본문이 비어진» 경우는 못 잡는다.** 집합 비교라 「있긴 한데 아무것도 안 한다」는 통과한다. +- ★**리네임·파일 이동을 소실과 구별하지 못한다** — 옛 경로에서는 «떨어진 것»으로 보인다. +- ★**trailer 이름이 «글자 그대로» 대조된다** — `method fieldref` 는 통하고 `Pool.fieldref` 는 **안 통한다**. + ★초판 docstring 의 **유일한 작동 예시가 그 «안 통하는» 쪽**이어서, 따라 적은 사람은 면제에 실패했을 것이다(게이트② 지적). + ★고쳤고 「**출력에서 복사하라**」를 계약에 박았지만, ★**오타를 「틀렸다」가 아니라 「면제 안 됨」으로 읽는 성질은 남는다.** + ※바꿀 당시 repo 전 이력 **1,286커밋에 trailer 0건** ⇒ 깨진 기존 등재는 **없다**. +- ★**trailer 는 우회로다.** 급하면 누구나 적고 지나간다. 브레이크는 「사유가 형식상 필수」와 「커밋에 남아 보인다」뿐이다. + ★**더 조이지 않았다** — 실적 없는 검사기에 과설계라고 판단했다(그 판단 자체가 대가다). +- ★★**새 red 계급 — main 에도 있는 정의를 지우거나 개명한 브랜치는 «다음 base 당김»에서 red 가 되고, + 평범한 리팩터가 `Dropped-from-theirs:` trailer 를 달아야 한다.** 실례 = **`37ea5a13`**(커밋 메시지 자신이 + 「충돌은 `STATE.md`·`REPORT.md` 두 곳뿐 · 제품 코드 0」이라 적는데, 그 base 당김이 + `at_most_one_bootstrap_methods_attribute` 를 «떨어졌다»로 잡는다 — 브랜치가 **먼저 일반화해 치웠기** 때문이다). + ★**늘어난 7정의 중 «3»이 이 계급**이라 예외가 아니라 **계급**이다. + ★**처분은 ⒜(trailer 로 면제)** 다 — ⒝(술어 좁히기)는 이 검사기의 존재 이유인 **초발 소실을 다시 놓치고** + (docstring 이 이미 그 면제를 넣었다 빼 본 기록을 갖고 있다), ⒞(방치)는 **남의 PR 에 비용을 떠넘긴다**. + ★**대가**: 브랜치 주인이 «자기가 지운 것»을 trailer 로 선언해야 한다(이름은 **출력에서 글자 그대로 복사**). + ※**오늘 실제로 red 인 살아 있는 PR 은 0건**이다(열린 PR = 이 PR 하나 · 그 범위는 rc=0) — ★그러나 그것은 + «오늘의 값»이지 계급의 소멸이 아니다. +- ★**머지마다 몇 초가 붙는다** · DoD 블록에도 들어가 **로컬 실행에도** 붙는다. + ★단 **넓힘의 시간 비용은 «유의하지 않다»**(위 ⒟ — 판본 차이 ≈0.8s/머지 ↔ 같은 판본 재실행 편차 2.6배). ★「늘지 않았다」가 아니다. + +## CI 배선 + +새 워크플로 파일 **0** — `rust.yml` 에 `merge_drops` 잡을 얹었다(`fetch-depth: 0` — 얕은 체크아웃에는 머지 부모가 없다). +★**파리티 락이 같이 움직인다**: `python3` 스텝은 DoD 블록에도 있어야 하므로 `CLAUDE.md` DoD 블록에 같은 줄을 넣었고, +`check-dod-ci-parity` 가 **「명령 7개 · toolchain 2개로 둘 다 일치」**로 답한다. diff --git a/scripts/check-merge-dropped-symbols.py b/scripts/check-merge-dropped-symbols.py new file mode 100755 index 00000000..287ca449 --- /dev/null +++ b/scripts/check-merge-dropped-symbols.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Report definitions that were in the branch a merge pulled from, and are not in its result. + +What this answers: *did a conflict resolution silently drop one side's work*. That failure is +invisible to everything else we have, which is why it exists. Measured 2026-09-18 by reconstructing +the loss on `origin/main` and running every axis against it: the generator ran (rc=0), the +single-defect auditor passed (23 checks, rc=0), `test_class_format` passed (21), `test_fixture_pins` +passed (3), `check-dod-ci-parity` passed, `git status` was empty and there was no conflict marker. +Nothing went red. The only signal was counting names by hand, which is what this does instead. + +The real incident: two merges on one PR branch (e53b2142, 514d5b08) each dropped four definitions +that came from the branch being merged in -- `Pool.fieldref`, `MAKECONCAT_DESCRIPTOR`, +`make_concat_call_site` and the `LINKED` table. The suite stayed green because the fixtures those +build are committed bytes, so when the generator lost the code the expected output did not move. + +Why it is silent, precisely: the loss is invisible exactly when the dropped names are *consistent +with each other*. Dropping a slice that something else still calls raises AttributeError and gets +noticed; dropping a whole feature -- which is what taking one side of a conflict does -- leaves a +file that parses, runs and agrees with every committed artefact. Both were measured. + +Scope: a definition is flagged when it exists in the merge's second parent and not in the merge +result, looked for in every file that either the merged-in branch or the merge itself changed. +That second half costs something. Measured by running both filters over one window of 83 merges +(38b0df38..8c7b473f, the last 200 commits of origin/main at the time): the narrow filter reports +8 merges / 19 definitions, this one reports 10 / 26. The seven extra definitions come from two +merges -- 514d5b08 (six) and 37ea5a13 (one) -- and they are not all one kind. Four are the second +of the two real incidents, restored later and present in the tree today. The other three are the +class described next. On time: the measurement noise is larger than the difference between the two +versions -- three independent runs of the narrow filter over this window span 229s to 585s, a factor +of 2.6, while the gap between the versions in one back-to-back run was 70s over 83 merges, about +0.8s per merge. So there is no significant increase to report, which is a weaker claim than "no +slower" and the one the numbers actually support. A pull request carries 0-2 internal merges. The +price of widening is reading, not waiting. + +A branch that deletes or renames a definition main still has goes red on its next base pull, and an +ordinary refactor then has to carry a trailer to say so. 37ea5a13 is exactly that: it pulled +origin/main into a branch that had already generalised `at_most_one_bootstrap_methods_attribute` +into `at_most_one_of_each_single_class_attribute`, and the pull reported the old name as dropped. +Three of the seven extra definitions are this class, so it is a class and not a corner. "CI only +looks at origin/main..HEAD" is not a reason to discount it -- that range is precisely where this +lands, on the next base pull of an open branch. + +That deliberately also flags *our* intentional deletions, because from the outside the two +look identical -- which is the whole difficulty. Saying which is which is a judgement, so it is +recorded as one, on the merge commit: + + Dropped-from-theirs: method fieldref -- superseded by the new pool builder, see + +One trailer per name, a reason after `--`, and both are required. THE NAME MUST BE COPIED FROM THIS +CHECK'S OWN OUTPUT, character for character -- it is matched literally, so `method fieldref` works +and `Pool.fieldref` does not, even though the second reads better. Run the check, copy the name it +prints after the colon, paste it. (An earlier version of this docstring used the prettier form as its +only worked example, which meant anyone who followed it got no exemption and lost a round.) + +The list lives in the merge commit message rather than in a file on purpose: a file accumulates +entries that outlive the merge they excused and quietly turns the check off, while a trailer can +only ever excuse the one commit it is written on. + +Usage: + check-merge-dropped-symbols.py [] # default: origin/main..HEAD + +Exit: 0 nothing dropped, or everything dropped is accounted for by a trailer + 1 something was dropped and not accounted for + 2 could not run (bad range, no git) + +`-s ours` merges -- "record these upstream cuts as ancestors" -- drop the whole other side on +purpose, and one trailer per name would mean hundreds. They use the wildcard form instead: + + Dropped-from-theirs: * -- recorded as a merged ancestor only, our tree is authoritative + +There is deliberately no structural exemption for them. The obvious one, "skip a merge whose tree +equals its first parent's", was written and then removed: it is true of `-s ours` *and* of a +resolution that took our side wholesale, and the second of those is the exact failure this exists +to catch. Measured -- with that exemption in place, a synthetic merge that discarded the other +side's new function passed. So the wholesale case has to be declared rather than inferred. + +A squash landing has one parent, so there is nothing for this to compare and it reports nothing. +That is not a gap: the merges this catches are the base-pulls *inside* a branch, and those are +checked while the pull request is open, whichever way the branch is eventually landed. +""" + +import re +import subprocess +import sys + +# Both languages the repo builds fixtures and code in. The method patterns matter as much as the +# top-level ones: of the four definitions the real incident dropped, one (`fieldref`) was a method +# inside `class Pool`, so a check that only looked at column zero would have found three of four. +PATTERNS = { + ".py": [ + (re.compile(r"^def ([A-Za-z_]\w*)"), "def {}"), + (re.compile(r"^class ([A-Za-z_]\w*)"), "class {}"), + (re.compile(r"^([A-Z][A-Z0-9_]*)\s*="), "{}"), + (re.compile(r"^ def ([A-Za-z_]\w*)"), "method {}"), + ], + ".rs": [ + (re.compile(r"^(?:pub(?:\([\w:]+\))?\s+)?(?:async\s+)?fn ([A-Za-z_]\w*)"), "fn {}"), + (re.compile(r"^(?:pub(?:\([\w:]+\))?\s+)?(?:struct|enum|trait|union) ([A-Za-z_]\w*)"), "type {}"), + (re.compile(r"^(?:pub(?:\([\w:]+\))?\s+)?(?:const|static) ([A-Za-z_]\w*)"), "const {}"), + (re.compile(r"^ (?:pub(?:\([\w:]+\))?\s+)?(?:async\s+)?fn ([A-Za-z_]\w*)"), "method {}"), + ], +} + + +def run(*args): + result = subprocess.run(["git", *args], capture_output=True, text=True) + return result.stdout if result.returncode == 0 else None + + +def symbols(rev, path): + """The definitions `path` holds at `rev`, or None if it is not a file we read.""" + for suffix, patterns in PATTERNS.items(): + if path.endswith(suffix): + break + else: + return None + blob = run("show", f"{rev}:{path}") + if blob is None: + return set() + found = set() + for line in blob.split("\n"): + for pattern, shape in patterns: + match = pattern.match(line) + if match: + found.add(shape.format(match.group(1))) + break + return found + + +def excused(merge): + """Names the merge commit itself says were dropped on purpose, with a reason.""" + message = run("log", "-1", "--format=%B", merge) or "" + names = set() + for line in message.split("\n"): + match = re.match(r"^Dropped-from-theirs:\s*(.+?)\s+--\s+(\S.*)$", line.strip()) + if match: + names.add(match.group(1).strip()) + return names + + +def excuses_everything(accounted): + """`Dropped-from-theirs: * -- ` covers a merge that keeps our tree on purpose.""" + return "*" in accounted + + +def check(merge): + """(findings, files_examined) for one merge commit. findings is a list of (path, name).""" + parents = (run("rev-list", "--parents", "-n", "1", merge) or "").split() + if len(parents) < 3: + return [], 0 # not a merge: a squash or an ordinary commit has nothing to compare + ours, theirs = parents[1], parents[2] + base = (run("merge-base", ours, theirs) or "").strip() + if not base: + return [], 0 + # Both what the merged-in branch touched and what the merge itself touched. Restricting this to + # the first set was the original shape and it was wrong: a resolution can revert a file the other + # branch never touched -- "fixed the conflict in A and put B back" -- and that is this check's + # whole reason for existing. Measured on the second of the two incidents, 514d5b08, by calling + # check() on each version: the narrow filter examines 4 files, finds 0 and passes; this one + # examines 13 and names the same four definitions the first incident dropped. The narrow filter + # was not blind -- it read four files and still missed it, because none of the four was where + # the loss landed. The cost is real and is recorded in Scope above. + changed = set((run("diff", "--name-only", base, theirs) or "").split("\n")) | set( + (run("diff", "--name-only", base, merge) or "").split("\n") + ) + findings = [] + examined = 0 + accounted = excused(merge) + if excuses_everything(accounted): + return [], 0 + for path in filter(None, changed): + theirs_symbols = symbols(theirs, path) + if theirs_symbols is None: + continue + examined += 1 + for name in sorted(theirs_symbols - symbols(merge, path)): + if name not in accounted: + findings.append((path, name)) + return findings, examined + + +def main(): + rev_range = sys.argv[1] if len(sys.argv) > 1 else "origin/main..HEAD" + merges = run("rev-list", "--merges", rev_range) + if merges is None: + print(f"cannot read {rev_range}", file=sys.stderr) + return 2 + + merges = [m for m in merges.split("\n") if m] + total = 0 + for merge in merges: + findings, examined = check(merge) + subject = (run("log", "-1", "--format=%s", merge) or "").strip() + if findings: + print(f"✗ {merge[:8]} {subject[:60]}") + for path, name in findings: + print(f" {path}: {name} — in the merged-in branch, not in the result") + total += len(findings) + else: + print(f" ✓ {merge[:8]} ({examined} file(s) examined)") + + print(f"{len(merges)} merge(s) in {rev_range}: {total} definition(s) dropped without a trailer") + if total: + print( + "If a drop was deliberate, say so on the merge commit:\n" + " Dropped-from-theirs: -- ", + file=sys.stderr, + ) + return 1 if total else 0 + + +if __name__ == "__main__": + sys.exit(main())