fix: 경로로 지정한 eject 가 다른 디렉터리의 동명 변환본을 지우지 않는다 - #333
Conversation
`conv_origin` 이 provenance 헤더의 값을 basename 으로 축약해 저장하던 것을, 변환본 자신의 미러 서브디렉터리 + 헤더의 basename 으로 재구성한 sources 상대 경로로 바꾼다. ingest 는 미러 서브디렉터리와 헤더 라벨을 같은 `src_rel` 에서 파생하므로 헤더의 디렉터리 성분은 새 정보가 아니고, 손편집으로 둘이 갈릴 때 믿어야 하는 쪽은 변환본이 실제로 미러링한 자기 위치다. basename 만 필요한 소비자는 `origin_name()` 을 거치게 하고, 고아 판정은 저장값이 이미 서브디렉터리를 품고 있으므로 `sources/<origin>` 존재 확인으로 줄인다. 헤더에 파일명 성분이 없는 값(`source: /`)은 빈 origin 센티널을 유지해, 서브디렉터리 이름을 원본 이름으로 착각하지 않게 한다. 동작 변화는 없다(다음 커밋의 경로 분기 수정을 위한 준비). 업스트림 c6d359d 와 동일 입력에서 `eject --dry-run` 선택 결과 27건, `eject --orphans --dry-run` 6시나리오 출력이 모두 바이트 단위로 동일함을 실측했다. 동봉한 핀 3건은 회귀 방지용이며 수정 전에도 통과한다: 경로형 헤더 + 중첩 변환본, 경로형 헤더 + flat 변환본, 파일명 없는 헤더. 업스트림 #324
`eject sub/report.html` 이 `sources/report.html` 의 변환본까지 지웠다. 경로
분기가 양변을 basename 으로 줄여 비교해, 경로 지정이 사실상 basename 매칭으로
퇴화해 있었다. 사용자가 명시적으로 좁혀 지정했는데 요청하지 않은 변환본이
종료 코드 0 과 함께 사라진다.
인자를 먼저 정규화(`selector()`)한 뒤 경로 분기가 sources 상대 경로끼리
비교하도록 바꾼다.
- `sub/report.html`·`./report.html` → sources 기준 경로. 그 경로에서 만들어진
변환본만 매칭한다.
- `sources/...` 접두는 상대형에서도 벗겨 KB 기준 ref 와 sources 상대 경로를
함께 얻는다(업스트림은 절대·상대 어느 쪽에서도 접두를 벗기지 않았고,
`eject sources/report.html` 이 3건이던 것은 정확 ref 1건 + 동명 basename
변환본 2건이었다).
- `runs/sources/...` 는 변환본 자신을 가리키므로 정확 ref 매칭만 한다.
- 절대 경로만 `resolve()` 로 환원한다. 상대 경로를 cwd 기준으로 resolve 하면
KB 안에서 친 `sub/report.html` 이 KB 밖 경로가 되어 결함이 되살아난다.
KB 루트도 함께 resolve 한다(/tmp -> /private/tmp 심링크).
- `sources/` 밖의 원본은 ingest 가 항상 flat 변환본을 만들므로, 그 경로 지정은
flat 변환본(재구성 origin 이 bare basename 인 것)만 매칭한다. KB 밖 원본을
적재하는 정당한 용례는 유지되고 중첩 변환본에는 닿지 않는다.
- `normpath`/casefold 는 쓰지 않는다. 경로는 적힌 그대로 비교한다.
의도된 부수 변화 4건(핀으로 고정):
1. `sub/../report.html` 처럼 접히지 않은 상대 경로는 2건 매칭 → 0건(exit 1).
2. 대소문자가 다른 경로는 대소문자 무시 파일시스템에서도 매칭하지 않는다.
3. 심링크 디렉터리 이름을 거친 상대 경로는 매칭하지 않는다(실제 경로나 절대
경로로 지정). 한계로 문서에 기록한다.
4. 절대 경로와 정규화 가능한 상대 경로(`./sources/x`, `sources//x`)가 그 ref
자체를 매칭하게 된다. 새로 걸리는 것은 언제나 인자가 가리킨 그 파일 하나뿐
이고 미러 변환본이 새로 걸리는 입력은 없지만, `--delete-original` 과
결합하면 업스트림이 지우지 않던 원본 1건을 지운다. KB 밖 심링크가
`sources/` 안을 가리키는 경우도 같다(resolve 로 실제 원본에 도달한다).
이슈 수용 기준 AC2("`eject report.html` 이 sub/ 를 안 건드린다")는 코드로
만족시키지 않는다. bare filename 은 경로 지정이 아니라 의도적으로 넓은 매칭이며
(AC3 가 그 불변을 요구한다) `/` 가 없어 경로 분기에 도달하지도 않는다. 좁히려면
`./report.html` 또는 `sources/report.html` 로 지정한다 — 문서에 명시했다.
`eject --orphans` 출력은 6시나리오(정상 ingest 산출물 / legacy bare 헤더 /
flat 변환본 + 경로 헤더 / 헤더·미러 불일치 / 진짜 고아 / 헤더 없음)에서
업스트림 c6d359d 와 바이트 단위로 동일함을 실측했다.
업스트림 #324
리뷰에서 나온 두 건을 고치고 핀 3건을 동봉한다. 1) `Path.resolve()` 는 심링크 루프를 `OSError` 가 아니라 `RuntimeError` 로 보고하고(3.12 `pathlib.py` check_eloop), NUL 이 박힌 경로는 `ValueError` 를 던진다. `except OSError` 로만 감싼 탓에 `eject <루프경로>` 가 사용자에게 전체 트레이스백을 뱉었다 — 삭제 이전 단계라 데이터 손실은 없지만 c6d359d 대비 회귀다. 두 곳(KB 루트 환원, 인자 환원) 모두 확장한다. 실측: `ln -s b a; ln -s a b` 후 `eject <루프>/absent.html --dry-run` 이 트레이스백 + exit 1 이었던 것이, c6d359d 와 같은 `no source matches` + exit 1 로 돌아온다. 2) 경로 분기의 `src_rel is not None` 가드가 빈 문자열을 배제하지 못했다. 루트 경로(`/`, `//`)는 `p.name == ""` 라 `src_rel` 이 "" 가 되고, 이는 `source: /` 헤더에 저장하는 빈-origin 센티널과 같은 값이다. 그래서 `eject /` 가 그 변환본을 선택했다(c6d359d 도 동일해 회귀는 아니다). `if src_rel:` 로 바꿔 원천 차단한다. 빈 `src_rel` 은 어차피 가리킬 원본이 없으므로 매칭이 좁아지는 방향으로만 바뀐다. 핀 3건(전부 수정 전 실패 확인): - 심링크 루프 인자: 종료 코드 != 0 이고 출력에 Traceback 이 없다. - `eject /`: 아무것도 선택하지 않는다. - 절대 경로 + `--delete-original`: 지목한 원본과 그 변환본 **정확히 1쌍**만 지우고 다른 디렉터리의 동명 형제는 원본·변환본 모두 남긴다. 기존 절대경로 케이스가 `--delete-original` 없이 변환본만 검사해 비어 있던 자리다. `eject --orphans` 6시나리오 출력은 이 변경 뒤에도 c6d359d 와 동일하고, select 27케이스도 직전 커밋과 동일하다(정상 경로는 좁아지지 않았다). 업스트림 #324
"최상위 것만 빼려면 `./report.html` 이나 `sources/report.html`" 이 두 형태를 등가처럼 나열했는데, 바로 위 표는 둘을 구분한다 — `./report.html` 은 변환본만, `sources/report.html` 은 원본까지 매칭한다. `--delete-original` 을 함께 쓰는 독자가 원본이 지워지는 조건을 오해할 수 있어 한정어를 넣는다. ko/en 동일 문단. 업스트림 #324
justinjoy
left a comment
There was a problem hiding this comment.
결론: CHANGE REQUEST
접근이 옳다. "헤더의 디렉터리 성분을 버리고 변환본 자신의 미러 경로를 믿는다" 는 규칙은 factlog/cli.py:2173-2193 에서 rel_parent 와 source_label 이 같은 src_rel 에서 파생되므로 헤더의 디렉터리 성분이 애초에 독립 정보가 아니라는 사실로 뒷받침된다. 손으로 확인만 한 게 아니라 측정했다:
- 하니스: HEAD 83 passed / 0 failed, 신규 하니스를
c6d359d에 걸면 70 passed / 13 failed (13+8=21 신규, 본문과 일치) - 커밋 A 의 "동작 0비트" 는 적극적 반증 시도에도 살아남았다 — select 280 케이스 + orphan 스캔 234 KB 형태(
source: /,source: ../x,//x, 공백만, 헤더 없음 × 변환본 깊이 3종 × 소스셋 6종) 전부c6d359d와 바이트 동일 --orphans불변은 본문이 말한 6시나리오보다 훨씬 강하게 성립한다 — 234 형태에서origin/main과 diff 무출력. 대수적으로도 동치다("/" in origin⟺subdir != ".")- 뮤테이션 10종 중 8종을 신규 하니스가 죽인다(basename 되돌리기 → 11 fail, 빈
src_rel가드 → 1, no-resolve → 3, orphan 페어링 → 2 등) - 인자 431건 fuzz 에서 미러 변환본이 새로 걸리는 입력 0건 — 재현됨. 새로 매칭되는 18/280 은 전부 원본이거나 절대경로가 명시적으로 지목한 그 ref 다
그런데 이 PR 이 문서로 내건 보장("경로는 좁다 — 다른 디렉터리의 동명 파일에 절대 닿지 않는다")이 여전히 깨지는 입력을 세 갈래로 재현했다. 전부 무음이고 rc=0 이며 끝에 파일이 지워진다. 구멍은 한 분기(factlog/cli.py:2407/:2414 절대경로 폴백)에 몰려 있어 수정 범위는 작다.
Blocker
1. 절대경로의 "KB 안인가" 판정을 문자열 접두사로 하기 때문에, 빗나가면 #324 가 없애려던 basename 매칭으로 조용히 강등된다.
relative_to(troot) 결과에 startswith("sources/") 를 거는데(:2407), 미스하면 멈추지 않고 src_rel = nfc(p.name)(:2414)로 떨어진다. 재현한 트리거 넷:
(a) sources/ 가 심링크인 KB. ingest 는 sources_dir = (target / "sources").resolve()(cli.py:2050)로 파일시스템 포함 관계로 판정하는데 eject 만 문자열 접두사를 쓴다. 두 오라클이 갈린다:
<KB>/sources -> <base>/docs 인 상태에서
factlog eject <KB>/sources/sub/report.html --delete-original
main: conv=['runs/sources/report.html.md', 'runs/sources/sub/report.html.md']
PR : conv=['runs/sources/report.html.md'] ← 엉뚱한 파일만 지우고
지목한 중첩 변환본은 살아남음
업스트림은 최소한 옳은 것도 함께 지웠는데, 수정 후에는 틀린 것만 지운다.
(b) 대소문자 비구분 파일시스템(macOS/Windows 기본). Path.resolve() 는 macOS 에서 대소문자를 정준화하지 않는다(실측: SOURCES 가 그대로 남음). 특별한 타이핑이 필요 없고 --target 철자만 달라도 걸린다:
factlog eject "$BASE/wiki/sources/sub/report.html" --target "$BASE/Wiki" --delete-original
→ rc=0, runs/sources/report.html.md 삭제 + 해당 후보 행 supersede
(c) 선행 이중 슬래시. Path("//sub/report.html").is_absolute() 가 POSIX 에서 True 라 절대 분기로 가고, relative_to 실패 후 basename 폴백으로 떨어진다:
factlog eject //sub/report.html → runs/sources/report.html.md 삭제
factlog eject sub//report.html → runs/sources/sub/report.html.md 삭제
같은 경로의 두 철자가 다른 파일을 지운다.
(d) KB 밖 절대경로가, KB 안 원본과 이미 짝지어진 평면 변환본을 매칭한다. 문서는 "하위 디렉터리 변환본은 그 파일에서 나왔을 수 없다" 로 이 규칙을 정당화하지만, 코드는 그 평면 변환본이 이미 sources/<basename> 과 짝인지 확인하지 않는다:
OUT=$(mktemp -d); echo unrelated > "$OUT/report.html"
factlog eject "$OUT/report.html" --target "$KB" --purge
→ runs/sources/report.html.md 삭제 (헤더는 `source: report.html`, sources/report.html 존재)
+ 후보 행 PURGE — 재추출 없이는 복구 불가
eject 에는 확인 프롬프트가 없고 rc=0 에 경고도 없다. 회귀는 아니지만(main 은 양쪽 다 건드린다), 이 PR 이 함께 싣는 docs/reference/ignore-eject.md 가 "절대 경로는 KB 기준 ref 로 환원", "SUB/report.html 은 아무것도 매칭하지 않고 종료 코드 1" 이라고 단언하므로 문서가 거짓이 된다.
수정 방향 두 가지를 검증했다:
troot옆에sroot = (target / "sources").resolve()를 두고troot환원 전에p.relative_to(sroot)를 시도. 이 패치를 적용해 전체 차분(7 픽스처 × 15 인자형)을 재실행한 결과 비-심링크 105 프로브에서 PR 과 차이 0, 심링크 케이스는conv=['runs/sources/sub/report.html.md'] orig=['sources/sub/report.html']로 교정된다. 더 근본적으로는disk_refs로{(st_dev, st_ino): ref}인덱스를 만들어 identity 로 환원하면 (a)(b)(c) 가 한 번에 닫힌다.- basename 폴백에 가드:
fallback = base if f"sources/{base}" not in disk_refs else None.factlog ingest /elsewhere/report.html정당 케이스는 그대로 보존하면서(그 시나리오엔sources/report.html이 없다) (d) 를 죽인다.
케이스 추가 요청: sources/ 가 심링크인 KB, 대소문자 어긋난 절대경로, //sub/report.html, KB 밖 절대경로 + KB 안 동명 원본.
2. 예외 확대가 죽은 코드이고, 주석이 사실과 다르며, 전용 테스트가 아무것도 고정하지 않는다.
cli.py:2372 주석은 "resolve() 가 심링크 루프를 RuntimeError 로 보고한다 / 어느 쪽도 트레이스백으로 새지 않는다 — 해석 불가 인자는 그냥 아무것도 매칭하지 않는다" 고 적었다. 세 부분 다 틀렸다:
requires-python = ">=3.11"의 모든 버전에서resolve()는 비-strictrealpath라 심링크 루프에 예외를 던지지 않는다(3.14.6 실측: 경로를 그대로 반환).- 뮤테이션
_RESOLVE_ERRORS = (OSError,)→ 스위트 83 passed / 0 failed.(OSError, RuntimeError)로ValueError만 빼도 83/0.tests/test_eject_cmd.sh:199-209의 심링크 루프 케이스가 판별력이 없다. (NUL 박힌 경로는execve가argv에 NUL 을 못 실으므로 CLI 에서 도달 자체가 불가능하다.) - "그냥 아무것도 매칭하지 않는다" 도 거짓 — 해석이 실제로 실패하면 B1 과 같은 폴백에 닿는다:
LOOP=$(mktemp -d); ln -s "$LOOP/b" "$LOOP/a"; ln -s "$LOOP/a" "$LOOP/b"
factlog eject "$LOOP/a/report.html" --target "$KB"
→ rc=0, runs/sources/report.html.md 삭제
(OSError,) 로 되돌리고 근거를 지우거나, 튜플을 유지하되 주석을 사실대로 고쳐달라. 어느 쪽이든 :199 케이스는 뮤테이션에서 죽는 것으로 교체해야 한다 — 자연스러운 형태는 위 재현(살아있는 변환본과 basename 이 겹치는 루프 경로)이다.
3. factlog/common.py:310-315 의 계약 docstring 이 이 PR 로 거짓이 된다.
"every basename-keyed consumer (paired_conversion, eject) is unaffected by the header format — the subdir that #214 encodes lives in the conversion's own mirrored path, not in this pairing signal" 라고 적혀 있는데, 이 PR 이후 eject 는 basename-keyed 가 아니라 <mirror subdir>/<basename> keyed 다. 후속 유지보수자가 origin 처리를 건드리기 직전에 읽는 바로 그 주석이고, #324 를 만든 영역이다.
4. 하니스 한 케이스가 오탐을 계약으로 고정한다.
"an absolute original outside the KB matches only a FLAT conversion" 케이스의 픽스처 seed_dup 에서는 runs/sources/report.html.md 가 헤더 source: report.html 로 sources/report.html 에서 나왔음이 증명되는데, 그 KB 에 수집된 적 없는 $OUTDIR/report.html 을 지목하면 그걸 지워야 한다고 단언한다(= 위 B1(d)). 동작 자체는 방어 가능하나(cli.py:2186 이 sources 밖 원본에 src.name 만 저장해 구분 정보가 실제로 없다), 테스트는 정당한 케이스(평면 변환본이 실제로 그 외부 원본에서 나온 KB)를 증명해야지 충돌을 고정하면 안 된다.
Should fix
5. sub/report.html + --delete-original 이 조용히 no-op 한다. sources-상대 경로는 변환본만 선택하고 sources/sub/report.html 은 절대 선택하지 않아, original(s) to delete: 0 을 찍고 사용자가 넘긴 플래그가 아무 일도 안 한다(실측: conv=('runs/sources/sub/report.html.md',) orig=()). :2417 의 refs 에 f"sources/{norm}" 을 넣거나, 최소한 힌트를 내달라. 지금은 의도인지 누락인지 사용자가 알 수 없다.
6. ./report.html 의 기준점이 절대경로 형태와 반대다.
cd "$KB/sources/sub" && factlog eject ./report.html --target "$KB"
→ 사용자 cwd 에 있는 파일이 아니라 최상위 원본의 변환본을 지운다
문서화돼 있고 테스트로 고정된 의도적 선택이지만, 같은 명령줄에서 / 로 시작하면 파일시스템 기준, 아니면 sources 기준으로 앵커가 갈린다. "다른 디렉터리를 건드리지 않는다" 가 주제인 PR 이므로, 읽어들인 sources-상대 ref 를 출력하거나 명시적 철자를 요구하는 정도의 가드는 있어야 한다고 본다. 문서 계약을 유지하겠다면 이건 막지 않겠다.
7. 문서 정정. docs/reference/ignore-eject.md(+.en.md)의 "절대 경로는 KB 기준 ref 로 환원" 행과 "대소문자를 접지 않으므로 종료 코드 1" 문단은 B1(a)(b) 로, "sources/ 밖의 원본은 평면 변환본만 매칭" 문단은 B1(d) 로 반증된다. 수정과 함께 갱신해달라. 나머지는 동작과 정확히 일치했다 — 반직관적인 link/report.html → 무매칭 주장까지 확인했다.
Nit
:2422의raw는 죽은 코드다.{raw, norm}→{norm}뮤테이션에서 83/0 유지.raw는./나//를 품을 때만norm과 다른데 디스크/CSV ref 가 그렇게 적히는 일이 없어ref in refs를 만족할 수 없다.:2441의 "flat 변환본 + 경로 헤더" 좁아짐은 도달 불가다. 미러링(rel_parent)과 #214 경로 헤더(source_label)가 같은 커밋73cc76a에서 함께 들어왔으므로 릴리스된 factlog 가 그 형태를 만든 적이 없다. 순수 방어라는 건 괜찮지만 주석이 실제 마이그레이션 경로인 것처럼 읽힌다.- 헤더 정규식
[^|>]+?가common.py:321과 바이트 동일하게 중복돼 있다. 이번에 손대지 않은 판단은 옳다(conversion_origin()은 basename 을 반환하는데 eject 는 ref 가 필요하고, 헤더도 일괄 1패스로 읽는다). 다만common.conversion_origin을 가리키는 주석 한 줄, 그리고 후속으로parse_provenance_source(head) -> str | None추출은 남겨두자. - 절대경로가 원본 자체를 매칭하게 되는 것(18/280 diff 케이스 전부 이 형태)은 의도이자 문서화돼 있으나, 버그픽스 PR 에 새 삭제 능력이 실리는 것이므로 릴리스 노트에 한 줄 필요하다.
깨지 못한 것 (기록)
--orphans 불변, 커밋 A 등가성, sources/ 하위 심링크(디렉터리 심링크는 rglob 가 타지 않고, 파일 심링크는 링크만 unlink 되어 외부 타깃이 --delete-original 에서 살아남는다), 적대적 candidates.csv ref(../../etc/passwd)의 unlink 도달 — 모두 안전하다. 삭제는 전부 디스크에서 열거한 경로의 disk_refs[ref].unlink() 를 지난다. NFD/NFC 인자, 공백 박힌 파일명도 정확히 좁혀진다.
미확인
Windows 에서 Path("sub\\report.html") 은 / 가 없어 bare filename 분기로 가고, 그러면 모든 디렉터리의 동명 파일을 매칭한다(= #324 동작). macOS 에서만 실행했으므로 판정에 넣지 않았다. cli.py 의 enable_utf8_stdio 로 보아 Windows 를 지원하는 듯한데, selector 에서 os.sep 정규화가 필요한지 확인해달라.
Closes #324.
무엇을 고쳤나
factlog eject <하위경로>/<파일>이 다른 디렉터리에 있는 동명 원본의 변환본까지 삭제했다. 원본은 남고 변환본만 사라지므로, 손대지 않은 소스가 조용히 추출 입력을 잃는다. 종료 코드는 0 이고 경고도 없었다.실제 범위는 이슈 본문보다 넓었다 — 경로가 완전히 무시된다. 존재하지도 않는 디렉터리를 줘도 변환본이 전부 지워진다:
접근
conv_origin을 "변환본 자신의 미러 서브디렉터리 + 헤더 basename" 으로 재구성한다. 헤더의 디렉터리 성분은 쓰지 않는다.ingest 산출물에서 두 신호는 항상 같다(
cli.py:2172-2183에서rel_parent와source_label이 같은src_rel에서 파생). 갈리는 건 손편집·손이동뿐인데, 그때 헤더를 믿으면 매칭과--orphans가 같은 파일에 대해 다른 원본을 지목한다. 그리고factlog/common.py의conversion_origin()docstring 이 이미 "the subdir that #214 encodes lives in the conversion's own mirrored path, not in this pairing signal" 이라고 명문화하고 있다 — 헤더 우선 규칙은 그 계약과 모순된다.덕분에
--orphans판정 로직은 1비트도 바꾸지 않았다.커밋 A 는 동작 0비트
87e90a4는 저장 형태만 바꾸고 사용자 가시 동작을 전혀 바꾸지 않는다. 실측으로 증명했다 — select 27케이스 +--orphans6시나리오 출력을c6d359d산출물과diff해서 전부 무출력. 리뷰에서 독립 재현도 됐다(인자 431건 in-process 매칭 셋, CLI 53건, orphans 10시나리오 — 전부 동일).이렇게 나눈 이유는
346aac5(→de04d3a)의 diff 를 경로 분기 하나로 좁혀, 리뷰어가 "orphan 판정이 안 바뀌었다" 를 커밋 A 의 등가성만 보고 확인하게 하기 위해서다.수용 기준 두 개가 만족 불가능하다
AC2 ("
eject report.html이sub/를 안 건드린다")는 성립할 수 없다.report.html에는/가 없어 경로 분기에 도달조차 하지 않고 bare filename 분기로 가며, 그 분기의 넓은 매칭은 AC3 가 불변으로 요구하는 바로 그 동작이다. 두 기준이 배타적이다. 코드로 만족시키려 하지 않았고, 실제로 좁아지는 형태(./report.html,sources/report.html)를 docs ko/en 에 명시했다.AC4 ("legacy basename 헤더도 종전대로")도 잘못 적혔다 — legacy KB 야말로 경로 지정에 대해 좁아져야 정상이다. 실질 기준인 "legacy 헤더와 #214 경로 헤더가 동일하게 동작한다" 는 하니스의
for style in path legacy루프로 고정했다.자세한 정정은 이슈에 댓글로 남겼다.
함께 고친 것
심링크 루프에서 트레이스백으로 죽던 것. Python 3.12
Path.resolve()는 심링크 루프에OSError가 아니라RuntimeError를 던지는데except OSError로만 감쌌다.c6d359d는no source matches로 정상 종료하던 입력이다 — 회귀였다.(OSError, RuntimeError, ValueError)로 확장했고(NUL 박힌 경로는ValueError), 인자 환원과 KB 루트 환원 두 곳 모두 적용했다.빈
src_rel과 빈-origin 센티널 충돌. 루트 경로(/,//)는p.name == ""라source: /헤더의 센티널과 같아졌다. 가드를if src_rel:로 바꿨다(좁아지는 방향).의도된 부수 변화 (전부 핀으로 고정)
elsewhere/report.html,../report.html)가 2 매칭 → 0 매칭(exit 1)--delete-original과 결합하면 업스트림이 지우지 않던 원본 1건을 지운다. KB 밖 심링크가sources/안을 가리키는 경우(resolve 로 실제 원본 도달)도 같다. 넓어짐은 항상 "인자가 명시적으로 가리킨 그 파일" 하나에 한정된다 — 인자 431건 fuzz 에서 미러 변환본이 새로 걸리는 입력은 0건이었다검증
c6d359d8 passed / 13 failed--delete-original핀의 pre-fix 결과가 결함 범위를 더 보여준다: 업스트림은 지목한 원본은 안 지우면서 다른 디렉터리 파일을 지웠다--orphans6시나리오(정상 ingest / legacy bare 헤더 / flat 변환본 + 경로 헤더 / 헤더·미러 불일치 / 진짜 고아 / 헤더 없음) 출력이upstream/main과 바이트 동일. 세 번째가 가장 위험한 케이스인데 대량 삭제가 일어나지 않는다/etc/passwd,../../../../etc/passwd, NFD 한글, 백슬래시 등)으로 탐색 — 넓어진 케이스 0건, 오히려 4건이 좁아졌다pytest tests/unit655 passed / 13 skipped(변경 없음),uvx ruff@0.15.17 check .cleantests/*.sh40개 —upstream/main과 동일범위 밖으로 둔 것
factlog/common.py의conversion_origin()은 basename 축약 계약이tests/unit/test_conversion_provenance.py로 못박혀 있고 eject 는 그 함수를 쓰지 않는다(정규식을 인라인으로 중복 보유) — 손대지 않았다.