diff --git a/REPORT.md b/REPORT.md index 9f732458..6b514815 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,25 @@ # REPORT +## [2026-09-19] 부분 클론도 «거절»할까 — ★**아니다. 모호했던 것은 «환경»이 아니라 «호출 하나»였다**(rustjava-partial-clone-refusal-decision) +- 무엇을: 채택 제안 `2026-09-18-merge-drops-no-silent-git-failure#p0`(worklog json 기록). ★**거절하지 않는다** — 대신 `symbols()` 가 실패한 `git show` 를 «부재»로 읽기 «전»에 `git ls-tree` 로 그 경로가 트리에 있는지 묻는다. ★결정을 `preflight()` docstring 에 못박았다(다음 사람이 다시 묻지 않도록). +- ★★**추측하지 않고 «진짜 부분 클론»을 만들어 쟀다**(`--filter=blob:none` · 범위는 알려진 사고 머지 `e53b2142^..e53b2142`): + + | 클론 | promisor | 결과 | 시간 | + |---|---|---|---| + | 완전 | — | `6 dropped` · **rc 1** | 2.5s | + | blobless | ★**도달 가능** | `6 dropped` · **rc 1** — ★**완전히 동일** | 15.7s | + | blobless(신선) | ★**도달 불가** | `0 dropped` · ★★**rc 0 = green** | 8.3s | + +- ★★**그래서 제안은 «절반만» 맞다**: 「부분 클론이면 아무것도 없는 것처럼 보인다」는 **그 자체로는 거짓**이다 — promisor 가 닿으면 git 이 blob 을 투명하게 받아 와 답이 **바이트 동일**하다. ⇒ ★**거절은 «돌아가는 설정»을 막는 것**이고, shallow 와 달리 부분 클론은 **없는 것을 가져올 수 있다**. + ★**그러나 조용한 green 은 «실재»한다** — 조건이 좁을 뿐(promisor **도달 불가**). 그 상태에서 검사기는 `✓ … (3 file(s) examined)` 를 찍고 **rc 0** 으로 끝냈다(완전 클론은 6건 보고). +- ★**방법 주의(자기정정)**: 첫 오프라인 측정은 **오염됐다** — 앞선 온라인 실행이 그 blob 들을 이미 캐시해 «맞는 답»이 나왔다. 위 수는 **신선한 클론 + 읽기 «전»에 원격을 깨뜨린** 형상에서 다시 잰 것이다. +- ★**위험이 실현되나 — 실측 0**: `.github` 어디에도 `filter:` 가 **없고**(`merge_drops` 는 `fetch-depth: 0`) ⇒ 오늘 이 거짓 green 은 «개발자 노트북 + blobless + 오프라인»에서만 난다. ★그것이 **거절을 고르지 않은 이유**이지, **모호함을 남길 이유는 아니다** — 닫는 비용이 git 호출 하나로 드러났기 때문이다. +- ★**왜 `ls-tree` 인가(대안 둘을 각각 기각한 근거)**: ⒜**거절** — 탐지는 된다(`remote.origin.partialclonefilter = blob:none` · ★`rev-parse --is-shallow-repository` 는 **false** 라 현행 preflight 가 못 잡는다)지만 **맞는 답을 내는 경우까지 막는다** ⒝**에러 문면 대조** — 두 실패는 문면으로 갈리지만(`does not exist in` ↔ `could not fetch … from promisor remote`) **둘 다 exit 128** 이고, preflight 를 만든 회차가 이미 «git 판올림에 약하다»며 미뤘다 ⒞★**`ls-tree` 는 «트리 객체»로 답한다** — 부분 클론은 blob 이 없어도 **트리는 갖는다**. 두 클론에서 **동작 동일** 실측(있으면 1항목·없으면 빈 출력·rc 0). +- ★**양방향 축**(제품 스크립트): 거짓 green 형상 — 전 **rc 0 `0 dropped`** ↔ 후 ★**rc 2 `cannot measure: …:jvm-bytecode/src/class_definition.rs is in that tree but its content could not be read…`**. ★**과차단 0**: blobless + promisor 도달 가능은 고친 뒤에도 **rc 1 · 6 dropped** · 완전 클론도 **불변**. +- ★**비용 유의차 없음**: 같은 범위 3회씩 — 전 **7.78/7.99/7.05s** ↔ 후 **6.39/6.72/8.06s**(구간 겹침) · DoD 기본 범위 **0.46s → 0.33s**. 추가 호출은 `show` 가 **이미 실패한** 경로에서만 돈다. +- ★**재다가 발견했고 «고치지 않았다»**: 이 검사기의 **출력 순서가 실행마다 다르다**(findings 가 set 에서 나온다). `origin/main` 판본을 `PYTHONHASHSEED=random` 으로 5회 돌려 **순서 2종**(4+1). rc·집합은 동일하고 **줄 순서만** 움직인다 ⇒ ★**선행 결함**이고, 전/후 diff 에서 잠깐 «회귀»처럼 보였기에 적는다(후속 카드). +- 검증: DoD 9명령 · 아래 절. +- ★후속 추천: **findings 를 정렬해 두 실행을 비교 가능하게 할 것인가**(S). 상세 = `docs/worklog/2026-09-19-partial-clone-blob-vs-absence.md`. + ## [2026-09-18] 리터럴이 «아닌» 이름으로 exception() 을 부르는 자리는 몇 개인가 — ★**0 이다**(rustjava-count-nonliteral-exception-call-sites) - 무엇을: 채택 제안 `2026-09-18-named-exception-classes-are-loadable#p0`(worklog json `adoptedProposals` 기록). ★**순수 측정 회차 — `.rs` 0줄 · `scripts/` 0줄.** 산출은 «수»와 «술어»다. - ★**답**: bare `exception(` **847** = 정의 **1** + ★**리터럴(java/javax) 846** + 리터럴(그 밖) **0** + ★★**비리터럴 «0»**. diff --git a/STATE.md b/STATE.md index 410577b6..5f586b03 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,14 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [rustjava-partial-clone-refusal-decision] ★★**부분 클론을 «거절하지 않는다» — 모호했던 것은 환경이 아니라 «호출 하나»였다.** 채택 제안 `2026-09-18-merge-drops-no-silent-git-failure#p0`. + ★**진짜 blobless 클론으로 쟀다**: promisor **도달 가능**이면 답이 **완전 클론과 동일**(rc 1 · 6 dropped · 15.7s vs 2.5s) ⇒ ★거절은 «돌아가는 설정»을 막는 것. + ★**그러나 조용한 green 은 실재**: 신선한 blobless + promisor **도달 불가** → `0 dropped` · ★**rc 0**(완전 클론은 6건). + ★**처방**: `symbols()` 가 실패한 `show` 를 부재로 읽기 전에 **`ls-tree`** 로 트리 존재를 묻는다(부분 클론도 **트리는 갖는다**) ⇒ 문면 대조 없이 갈린다. + ★**양방향**: 전 rc 0(거짓 green) ↔ 후 ★**rc 2 «못 쟀다»** · ★과차단 0(도달 가능 blobless 는 여전히 rc 1) · 완전 클론 불변 · **비용 유의차 없음**(구간 겹침). + ★**위험 실현 0**: `.github` 에 `filter:` **0건**(`merge_drops` 는 `fetch-depth: 0`) — 그것이 «거절 안 함»의 근거이지 «모호함을 남길» 근거는 아니다. + ★**선행 결함 발견(미수정)**: 출력 순서가 실행마다 다르다(set) — `origin/main` 판본 5회에 순서 2종. rc·집합 불변(후속 카드). + ★결정을 `preflight()` docstring 에 못박았다 — 다음 회차가 같은 질문을 다시 하지 않도록. - [rustjava-count-nonliteral-exception-call-sites] ★★**사각의 «크기»를 쟀다 — 비리터럴 exception() 호출부는 «0» 이다.** 채택 제안 `2026-09-18-named-exception-classes-are-loadable#p0`. ★**순수 측정 · `.rs` 0줄 · `scripts/` 0줄.** ★**수**: bare `exception(` **847** = 정의 1 + **리터럴 846** + 그 밖 리터럴 **0** + ★**비리터럴 0** ⇒ 검사기가 보는 집합 = 실제 호출부 집합(지금은 일치). ★★**술어를 갈라야 답이 맞는다** — `exception(` 부분일치가 `assert_exception(` 등 **다른 함수 8종 41자리**(첫 인자가 `jvm`)를 쓸어담는다. 안 갈랐으면 ★**M=33 이라는 틀린 답**이었다. diff --git a/docs/worklog/2026-09-19-partial-clone-blob-vs-absence.json b/docs/worklog/2026-09-19-partial-clone-blob-vs-absence.json new file mode 100644 index 00000000..fe0368e0 --- /dev/null +++ b/docs/worklog/2026-09-19-partial-clone-blob-vs-absence.json @@ -0,0 +1,56 @@ +{ + "date": "2026-09-19", + "taskId": "rustjava-partial-clone-refusal-decision", + "summary": "Decided not to refuse partial clones. Measured on a real --filter=blob:none clone: with the promisor reachable the answer is identical to a full clone (rc 1, 6 dropped), so refusing would block a working setup. The silent green is real but needs the promisor to be unreachable - a fresh blobless clone with a broken remote printed 0 dropped and exited 0 where a full clone reports six. Fixed the ambiguity at the one call instead: symbols() asks ls-tree whether the path is in that tree before reading a failed git show as absence.", + "decision": "do not refuse partial clones; disambiguate missing-blob from absent-path via ls-tree", + "measurements": { + "range_measured": "e53b2142^..e53b2142", + "full_clone_rc": 1, + "full_clone_dropped": 6, + "blobless_promisor_reachable_rc": 1, + "blobless_promisor_reachable_dropped": 6, + "blobless_promisor_reachable_seconds": 15.66, + "full_clone_seconds": 2.46, + "blobless_promisor_unreachable_rc_before": 0, + "blobless_promisor_unreachable_dropped_before": 0, + "blobless_promisor_unreachable_rc_after": 2, + "blobless_clone_seconds": 1.91, + "blobless_git_dir_mb": 3.2, + "workflows_using_partial_clone": 0, + "timing_before_seconds": [7.78, 7.99, 7.05], + "timing_after_seconds": [6.39, 6.72, 8.06], + "dod_default_range_before_seconds": 0.46, + "dod_default_range_after_seconds": 0.33 + }, + "verification": [ + "axis on the product script: fresh blobless clone with remote pointed at an invalid host, before = '0 definition(s) dropped' rc 0 (silent green), after = rc 2 'cannot measure: :jvm-bytecode/src/class_definition.rs is in that tree but its content could not be read'", + "no over-blocking: blobless clone with the promisor reachable still returns rc 1 / 6 dropped after the fix", + "full clone unchanged: rc 1 / 6 dropped before and after", + "discriminator checked by execution in both clones: git ls-tree -- prints one entry for a present path and empty output for an absent one, rc 0 either way, identical in the partial clone which lacks the blob", + "detection axis for the refusal option (not taken): remote.origin.partialclonefilter = blob:none while rev-parse --is-shallow-repository = false, which is why the existing preflight misses it", + "risk realisation: no workflow in .github sets filter:, and merge_drops checks out with fetch-depth: 0" + ], + "changes": [ + "scripts/check-merge-dropped-symbols.py - symbols() raises CannotMeasure when ls-tree says the path is in the tree but show could not read it; preflight() docstring records the decision and the numbers behind it", + "docs/worklog/2026-09-19-partial-clone-blob-vs-absence.{md,json}, REPORT.md, STATE.md" + ], + "issues": [ + "The first offline measurement was contaminated: the earlier online run had cached the blobs, so it reported the correct answer. The recorded numbers come from a fresh clone with the remote broken before any read.", + "Pre-existing and not fixed here: the checker's finding order is not stable between runs (emitted from a set). Measured on origin/main's own version with PYTHONHASHSEED=random - five runs, two orderings. rc and the finding set are identical; only line order moves.", + "Message-based discrimination was available (fatal: path ... does not exist in vs fatal: could not fetch ... from promisor remote) but not used: both exit 128 and the round that added preflight already deferred message matching as version-fragile." + ], + "adoptedProposals": [ + "2026-09-18-merge-drops-no-silent-git-failure#p0" + ], + "proposals": [ + { + "title": "Sort the checker's findings so two runs can be compared", + "plainSummary": "The report lists what it found in a different order each time it runs, so comparing two runs shows differences that are not really there.", + "userBenefit": "Anyone checking whether a change affected the result can diff two runs and trust the answer, instead of re-reading the list to see that the same items moved.", + "why": "Measured this round on origin/main's own version: five runs under PYTHONHASHSEED=random produced two distinct orderings of the same six findings. It cost this round real time - the before/after diff looked like a regression until the original version was shown to disagree with itself. Findings are accumulated in a set and printed in iteration order; sorting at the print site is a one-line change.", + "tradeoff": "Sorting fixes an order that no one has depended on, so the risk is close to zero; the only cost is that the output no longer reflects traversal order, which nothing reads. Leaving it means every future before/after comparison of this checker carries the same false signal.", + "effort": "S", + "target": "scripts/check-merge-dropped-symbols.py" + } + ] +} diff --git a/docs/worklog/2026-09-19-partial-clone-blob-vs-absence.md b/docs/worklog/2026-09-19-partial-clone-blob-vs-absence.md new file mode 100644 index 00000000..1983a018 --- /dev/null +++ b/docs/worklog/2026-09-19-partial-clone-blob-vs-absence.md @@ -0,0 +1,82 @@ +# 2026-09-19 — Refuse partial clones too? No. The ambiguity was one call, not one environment. + +Round: `rustjava-partial-clone-refusal-decision` +Adopted proposal: `2026-09-18-merge-drops-no-silent-git-failure#p0` +— *"The check now refuses to run in a shallow clone, but a clone fetched without file contents can +still make it look like nothing is there."* + +## Decision + +**Do not refuse partial clones.** Fix the one call that could not tell "the path is not in this tree" +from "the blob is not here": `symbols()` now asks `git ls-tree` before reading a failed `git show` as +absence. The decision and its numbers are recorded in `preflight()`'s docstring so the next round +does not re-ask. + +## The proposal was half right, and the half matters + +Everything below was measured on a real `--filter=blob:none` clone of this repository, not reasoned +about. Range `e53b2142^..e53b2142` throughout — the known-accident merge, 6 dropped definitions. + +| clone | promisor | result | time | +|---|---|---|---| +| full | — | `6 definition(s) dropped` · **rc 1** | 2.5 s | +| blobless | **reachable** | `6 definition(s) dropped` · **rc 1** — *identical* | 15.7 s | +| blobless (fresh) | **unreachable** | `0 definition(s) dropped` · ★**rc 0 — green** | 8.3 s | + +So: + +- **"A partial clone makes it look like nothing is there" is false on its own.** With the promisor + reachable git fetches the blobs transparently and the answer is byte-identical. Refusing partial + clones would refuse a setup that works — and unlike a shallow clone, a partial one can go get what + it is missing. +- **The silent green is real, but the trigger is narrower**: the promisor has to be *unreachable*. In + that state the check printed `✓ … (3 file(s) examined)` and exited 0 where a full clone reports six + dropped definitions. That is exactly the failure class this script exists for. + +*Method note*: the first offline attempt reported the **correct** answer, because the earlier online +run had already cached those blobs. The measurement above is from a **fresh** clone with the remote +broken before any read. A contaminated clone answers the wrong question. + +## Is the risk realised here? No — measured, and it does not change the decision + +`git ls-files .github | grep filter:` → nothing; **no workflow uses a partial clone**, and +`merge_drops` explicitly checks out with `fetch-depth: 0`. So today the false green needs a developer +laptop with a blobless clone and no network. That is *why* refusing would be the wrong trade — it +would cost a working configuration to close a case nobody is in — but it is not a reason to leave the +ambiguity, because the cost of closing it turned out to be one git call. + +## Why `ls-tree`, and not the two alternatives + +- **Not refusal** (`remote.origin.partialclonefilter`): the detection works — measured `blob:none`, + while `rev-parse --is-shallow-repository` returns false, which is exactly why the existing + preflight misses this case — but it blocks the reachable-promisor case that gives the right answer. +- **Not matching git's error text**: the two failures *are* distinguishable by message — + `fatal: path 'X' does not exist in 'REV'` versus + `fatal: could not fetch from promisor remote` — but both exit 128, and the round that added + `preflight` already weighed and deferred message-matching as fragile across git versions. Nothing + here changes that. +- **`ls-tree` answers from the tree object**, which a partial clone holds even when it lacks blobs. + Measured identical behaviour in both clones: path present → one entry, rc 0; path absent → empty + output, rc 0. No message matching, no environment refused. + +## Axis (bidirectional, on the product script) + +| form | blobless + unreachable promisor | full clone | +|---|---|---| +| before | `0 dropped` · **rc 0** (silent green) | `6 dropped` · rc 1 | +| after | ★**rc 2** `cannot measure: …:jvm-bytecode/src/class_definition.rs is in that tree but its content could not be read…` | `6 dropped` · rc 1 | + +And the case that must **not** break: blobless with a reachable promisor, after the fix → **rc 1, 6 +dropped**. Over-blocking 0. + +**Cost**: none measurable. Same range, three runs each — before 7.78 / 7.99 / 7.05 s, after 6.39 / +6.72 / 8.06 s (overlapping ranges). DoD's default range `origin/main..HEAD`: 0.46 s → 0.33 s. The +extra call only runs when `show` has already failed. + +## Found while measuring, not fixed here + +The checker's **output order is not stable between runs** — findings are emitted from a set. Measured +on `origin/main`'s own version with `PYTHONHASHSEED=random`: five runs gave two different orderings +(4 + 1). The rc and the set of findings are identical; only line order moves. This is pre-existing +and unrelated to this round — it is noted because it briefly looked like a regression in the diff of +before/after output, and the next person comparing two runs deserves to know. Filed as a proposal. diff --git a/scripts/check-merge-dropped-symbols.py b/scripts/check-merge-dropped-symbols.py index 2ceada16..d038145a 100755 --- a/scripts/check-merge-dropped-symbols.py +++ b/scripts/check-merge-dropped-symbols.py @@ -136,10 +136,31 @@ def run(*args, absence_is_an_answer=False): def preflight(): """What has to be true before a failing `git show` can be read as "the path is not there". - Only one thing, and it is the environment this check is blind in: a shallow clone does not hold - the objects a merge's second parent needs, so every read of it fails and every failure used to - read as "empty". Measured on this repository at depth 10: merges that examine 20 files in a full - clone examined 0 and the run was green. + One environment is refused here, and it is the one this check is blind in: a shallow clone does + not hold the objects a merge's second parent needs, so every read of it fails and every failure + used to read as "empty". Measured on this repository at depth 10: merges that examine 20 files in + a full clone examined 0 and the run was green. + + PARTIAL CLONES ARE NOT REFUSED -- decided 2026-09-19, `rustjava-partial-clone-refusal-decision`, + adopting `2026-09-18-merge-drops-no-silent-git-failure#p0`. Do not "fix" this by adding a + `remote.origin.partialclonefilter` check here; the question was asked and answered with numbers: + + * A blobless clone (`--filter=blob:none`) with its promisor reachable gives the **identical** + answer to a full clone -- measured on `e53b2142^..e53b2142`: both rc 1, both 6 dropped + definitions -- it is only slower (15.7 s vs 2.5 s). Refusing it would refuse a setup that + works, and unlike a shallow clone it can fetch what it is missing. + * The failure is narrower than "partial clone": it needs the promisor to be **unreachable**. + Measured on a fresh blobless clone with the remote pointed at an invalid host, the same range + printed `0 definition(s) dropped` and exited **0** -- a green run where a full clone reports + six. That is the silent pass, and it is real. + * So the fix belongs where the ambiguity is, not in the environment: `symbols()` now asks + `ls-tree` whether the path is in that tree before reading a failed `show` as absence. Trees + are present in a partial clone even when blobs are not, so it separates the two without + matching git's error text (rejected as version-fragile by the round that added `preflight`). + + Detection axis, if it is ever needed: `git config --get remote.origin.partialclonefilter` + returns `blob:none`, while `rev-parse --is-shallow-repository` returns false -- which is why the + shallow check above does not catch this case. """ if (run("rev-parse", "--is-shallow-repository") or "").strip() == "true": raise CannotMeasure( @@ -156,9 +177,18 @@ def symbols(rev, path): else: return None # The only tolerated failure in the file: a path that is not in this tree is a real answer, - # and it means "no definitions here". preflight() has already ruled out the other reason. + # and it means "no definitions here". Everything else has to be ruled out before that reading + # is safe -- preflight() rules out a shallow clone, and the `ls-tree` below rules out the other + # environment that makes `show` fail on a path that *is* there (see partial clones, above). blob = run("show", f"{rev}:{path}", absence_is_an_answer=True) if blob is None: + if (run("ls-tree", rev, "--", path) or "").strip(): + raise CannotMeasure( + f"{rev}:{path} is in that tree but its content could not be read. In a partial clone this is " + "a blob the promisor remote did not hand over; reading it as 'no definitions here' is the " + "silent green this check exists to prevent. Restore access to the remote (or run " + "`git fetch origin` to materialise the missing blobs) and run again." + ) return set() found = set() for line in blob.split("\n"):