Ruff 0.16.8이 자동 수정을 되돌린 이유: 문법보다 먼저 지켜야 할 실행 순서
자동 수정이 통과해도 프로그램은 달라질 수 있다
Ruff 0.16.8은 새 규칙보다 자동 수정의 경계를 다시 그은 릴리스에 가깝다. 9월 16일 공개된 이 버전은 SIM109가 논리식의 피연산자 순서를 바꾸던 문제를 고쳤고, UP040이 여러 줄 타입 별칭에서 필요한 괄호를 보존하도록 수정했다. 같은 릴리스에서 UP040 수정은 항상 unsafe로 분류됐다.
세 변경은 서로 다른 버그처럼 보이지만 원인은 비슷하다. 변환기가 추상 구문 트리에서 같은 뜻처럼 보이는 조각을 합치거나 새 문법으로 옮길 때, 원본 코드가 갖고 있던 평가 순서와 괄호, 다른 모듈에 숨은 타입 변수 제약까지 함께 보존해야 한다. 결과가 파싱된다는 사실만으로는 충분하지 않다.
저는 Ruff 0.16.7과 0.16.8을 같은 최소 입력에 실행해 차이를 확인했다. SIM109 사례에서 0.16.7 수정본은 원래 False였던 결과를 None으로 바꿨고, 0.16.8 수정본은 False를 유지했다. 여러 줄 TypeAliasType 사례는 0.16.7이 "수정이 문법 오류를 만들었다"며 전체 변경을 되돌렸지만, 0.16.8은 괄호를 포함한 PEP 695 type 문장을 만들었다.
그림 1. 같은 falsey 결과라도 or 식의 피연산자 순서가 바뀌면 실제 반환값은 달라질 수 있다. 출처: github.com/astral-sh/ruff/pull/27824.
SIM109에서 바뀐 것은 비교식이 아니라 순서였다
SIM109는 y == z or y == w를 y in (z, w)로 합친다. 이 변환만 놓고 보면 자연스럽다. 문제는 같은 or 식에 다른 피연산자가 있을 때 생겼다.
x = None
y = "y"
z = "z"
w = "w"
print(x or y == z or y == w)
원본은 왼쪽부터 평가해 False를 출력한다. Ruff 0.16.7의 unsafe fix는 이를 y in (z, w) or x로 바꿨다. 앞의 membership 비교가 거짓이면 마지막 값인 x, 즉 None이 식의 결과가 된다. 불리언 문맥에서는 둘 다 거짓이지만 반환값은 달라졌다. or는 항상 bool을 반환하는 연산이 아니라 처음 만난 truthy 피연산자 또는 마지막 피연산자를 반환하기 때문이다.
0.16.8은 합쳐진 비교식을 일치한 비교들 가운데 가장 이른 소스 위치에 고정하고, 합치지 않은 피연산자와 위치순으로 다시 정렬한다. 위 입력은 x or y in (z, w)가 되어 원래 결과를 보존한다. PR #27824는 이 수정으로 모든 경우가 안전해졌다고 말하지 않는다. 합칠 두 비교 사이에 다른 피연산자가 끼어 있으면 연속된 in 비교와 전체 원래 순서를 동시에 만족시킬 수 없다. 그래서 규칙 문서의 fix는 여전히 항상 unsafe다.
UP040에는 괄호가 문법의 일부였다
UP040은 TypeAlias나 TypeAliasType을 PEP 695의 type 문장으로 옮긴다. 한 줄짜리 별칭에서는 간단해 보이지만, 함수 호출의 두 번째 인수로 여러 줄 union을 쓸 때 호출 괄호가 줄바꿈을 허용하는 문법 경계가 된다.
LongAlias = TypeAliasType(
"LongAlias",
int
| str
| float
)
0.16.7은 값 부분만 꺼내 type LongAlias = int 다음 줄에 | str, | float를 놓으려 했다. 괄호가 사라져 유효하지 않은 Python이 됐고, CLI는 자체 검증에서 오류를 찾아 수정을 되돌렸다. 편집기 연동처럼 이 보호가 동일하게 적용되지 않는 경로에서는 잘못된 코드가 남을 수 있다는 보고도 이슈 #28102에 기록돼 있다.
0.16.8은 call argument의 source range를 가져올 때 여러 줄 binary·boolean expression에 필요한 그룹 괄호를 함께 만든다. 제가 얻은 수정본은 다음과 같았고 Python 3.12 컴파일을 통과했다.
type LongAlias = (int
| str
| float)
그림 2. 호출 괄호는 장식이 아니라 여러 줄 union을 유효한 문법으로 유지하는 정보다. 출처: github.com/astral-sh/ruff/pull/28164.
"문법이 맞다"와 "안전한 수정"은 다른 판정이다
이번 릴리스는 UP040을 stub 파일까지 포함해 항상 unsafe로 바꿨다. 이유는 괄호 결함보다 넓다. 새 타입 별칭은 런타임 동작이 달라질 수 있고, 수정 범위 밖의 주석이 사라질 수 있다. 특히 다른 모듈에서 가져온 타입 변수는 Ruff가 그 정의까지 따라가지 않는다.
예를 들어 외부의 Ts가 bound를 가진 TypeVarTuple이어도 현재 파일에는 이름만 보인다. TypeAliasType(..., type_params=(Ts,))를 type Alias[Ts] = ...로 바꾸면 Ts를 제약 없는 일반 타입 변수처럼 표현할 수 있다. 원래의 kind, bound, constraint, default를 잃는 셈이다. 그래서 "파싱 가능"은 안전성의 하한선일 뿐이다.
Python 3.15에서 TypeVarTuple의 bound가 정식 인수가 됐지만 PEP 695에는 그 제약을 직접 옮길 문법이 없다. PR #28505는 bound나 constraint가 있는 TypeVarTuple·ParamSpec을 발견하면 UP040, UP046, UP047 진단 자체를 생략하도록 했다. 표현할 수 없는 의미를 억지로 새 문법에 맞추지 않는 쪽을 택한 것이다.
그림 3. 파싱 성공 뒤에도 평가 순서와 타입 변수의 kind bound constraint를 따로 확인해야 한다. 출처: docs.astral.sh/ruff/linter/#fix-safety.
CI에서는 safe와 unsafe를 다른 배포로 다뤄야 한다
Ruff는 --fix만 쓸 때 unsafe fix를 기본 적용하지 않는다. --unsafe-fixes를 명시해야 한다. 다만 설정 한 줄로 unsafe를 허용했다고 해서 모든 변경을 무검토 병합해도 된다는 뜻은 아니다. 0.16.8의 두 사례는 변환기가 스스로 syntax check를 통과해도 런타임 값이나 타입 의미가 달라질 수 있음을 보여 준다.
운영 방식은 다음처럼 나누는 편이 낫다.
- safe fix는 별도 diff로 만들고 기존 테스트를 실행한다.
- unsafe fix는 규칙 코드별로 허용하고, 자동 병합하지 않는다.
- 논리식 변환은 truthy/falsey뿐 아니라 실제 반환값과 side effect 순서를 검사한다.
- 타입 문법 전환은 대상 Python 버전으로 compile하고, type checker 결과도 비교한다.
- 다른 모듈의 타입 변수나 메타데이터가 의미에 관여하면 자동 수정보다 수동 변경을 택한다.
Ruff가 0.16.7에서 문법 오류를 감지해 변경을 되돌린 것은 좋은 마지막 방어선이었다. 하지만 SIM109 사례는 문법도 맞고 lint도 끝난 채 결과만 달라졌다. 테스트가 잡아야 하는 구간이다.
0.16.8을 읽는 실무 기준
이 릴리스를 "자동 수정 버그 두 개 해결"로만 보면 놓치는 부분이 있다. 첫째, AST가 같아 보이는 변환도 Python의 평가 순서와 반환값 계약을 보존해야 한다. 둘째, 괄호는 보기 좋은 포맷이 아니라 줄바꿈을 허용하는 문법 정보일 수 있다. 셋째, 소스 파일 밖의 제약을 해석하지 못하는 도구는 그 사실을 fix safety에 반영해야 한다.
업데이트 뒤에는 기존에 --unsafe-fixes를 켠 CI의 diff를 한 번 따로 살펴보는 것이 좋다. 0.16.8은 알려진 두 결함을 줄였지만 SIM109의 중간 피연산자 제한과 UP040의 외부 타입 변수 불확실성은 문서에 남아 있다. 도구가 unsafe라고 표시한 수정은 "대체로 맞는 자동 정리"가 아니라 의미 검토가 필요한 코드 변경으로 취급해야 한다.
모노레포라면 자동 수정 결과를 한 커밋에 모두 섞기보다 규칙별로 나누는 편이 원인 추적에 유리하다. 예를 들어 SIM109 변경에는 논리식의 실제 반환값을 검증하는 단위 테스트를 붙이고, UP040 변경에는 최소 지원 Python 버전의 compile과 정적 타입 검사를 붙일 수 있다. 수정 파일 수나 lint 감소량만 보면 의미 변화가 작은 정리처럼 보이지만, 이번 두 사례는 한 줄짜리 변환도 테스트 대상이어야 한다는 것을 보여 준다.
또 하나 볼 것은 도구가 실패했을 때의 행동이다. 0.16.7 CLI는 후보 fix가 문법 오류를 만들자 전체 변경을 되돌리고 오류를 냈다. 이 동작 덕분에 잘못된 파일이 조용히 남지는 않았다. 반면 런타임 의미가 바뀐 SIM109는 유효한 Python이어서 같은 보호를 통과했다. 자동 수정 파이프라인은 구문 검증을 기본 방어선으로 두되, 그 뒤에 프로젝트 테스트와 타입 검사를 반드시 이어야 한다.



댓글
댓글 쓰기