3-25. 적색 CI를 풀고, CI를 껐다 — 회귀 10건 추적과 함수 이름·TLA 결함

3-24는 그 닷새 동안 main CI가 한 번도 성공하지 못했다고 적었다. 이어진 사흘 동안 #4819 심볼 이관은 같은 속도로 계속됐고, 그 옆에서 다른 흐름 하나가 적색 CI의 원인을 하나씩 추적했다. 원인은 서로 독립인 회귀 10건이었다. 모두 고친 날 밤 CI 워크플로는 비활성화됐고, 그 뒤로는 적대적 재검증으로 찾은 함수 이름(.name), 낮춘 top-level await, TypeScript import 제거의 결함이 이어서 고쳐졌다.

이 편은 3-24의 기준 41a44cec(10월 9일 23:36 KST) 이후부터 10월 12일 23:19 KST의 8cd6a1b8까지 공개 main에 들어간 238커밋, 235개 PR(#5330부터 #5593까지)을 다룬다. 아래 변경은 main에 반영됐지만, 10월 13일 확인한 최신 GitHub 릴리즈와 npm @zntc/core의 latest는 여전히 v0.1.10이다.

날짜커밋핵심
10/1053단일 파일 출력의 합성 이름을 SymbolId 기준으로 마지막에 결정(Stage 3 데코레이터·generator _state·_super·_loop 등), 비단순 매개변수의 별도 scope, React Refresh·Emotion·Worklet·styled-components 변환의 그래프 유지
10/1188적색 CI의 원인 회귀 10건 추적과 수정(#5457), 파생 클래스 생성자 super() 뒤 문장의 그래프 유지, 임시 변수 SymbolId 즉시 연결, CI 워크플로 비활성화
10/1297Function.name·--keep-names 결함 연쇄 수정, 낮춘 top-level await의 모듈 코드 유실·실행 순서 수정, TS 타입 전용 import를 tsc처럼 제거, emitDecoratorMetadata를 tsc와 일치

날짜와 개수는 위 커밋까지의 main 커미터 시각을 한국 시간으로 집계했다. 235개 PR은 모두 main에 병합됐고, 병합일(KST) 기준으로 날짜순 53·88·94개다. 10월 12일은 PR 몇 개가 커밋 여러 개로 들어가 커밋 수가 더 많다. 제목 유형은 fix 153개, refactor 40개, feat 23개, test 14개, chore 2개, perf 1개이고, 유형 없이 #4819로 시작하는 제목이 2개다. 그 사이 번호 29개는 같은 기간에 열린 이슈다.

원인은 열 개였다

3-24에서 적었듯 main CI는 10월 5일 새벽 이후 성공한 실행이 없었다. 이 편 범위의 커밋이 띄운 CI 실행 138회도 28회 실패, 110회 취소로 끝났고 성공은 없다.

10월 11일 18시 15분 KST, 추적 이슈 #5457과 원인별 이슈 #5445부터 #5456까지가 1분 안에 함께 열렸다. #5457 본문은 실패를 원인 커밋별로 나눴다. 각 원인 커밋을 그 부모와 함께 직접 빌드해 확인했고, 당시 main에서 통합 테스트 27건, NAPI·CLI 3건, 실제 프로젝트 스모크 1건이 실패한다고 적었다. 공통 원인으로는 세 가지를 들었다.

  • CI 없이 병합됐다. main CI는 concurrency 설정 때문에 거의 매번 취소됐고, PR도 CI job이 취소된 상태로 자동 병합된 경우가 있었다(#5346·#5365·#5418·#5436 등).
  • #4819 이관 뒤에 이름·위치 기반 소비자가 남아 있었다. 심볼로 옮긴 값을 아직 이름 문자열이나 소스 위치로 찾는 코드가 있었다.
  • 입력의 strict 여부와 출력의 strict 여부가 달랐다.

회귀 10건 가운데 7건은 원인 커밋이 이 편 범위 안, 그것도 10월 10일과 11일에 들어온 것이었다. 나머지 3건은 3-24 구간(10월 7일부터 9일)의 커밋에서 시작됐다. 3-24 끝의 실패에 이 편 앞부분의 회귀가 더 쌓인 셈이다. 원인을 고친 PR은 그날 저녁부터 자정 직전까지 이어졌다.

  • #5459 — CJS 래퍼 매개변수가 SymbolId를 갖게 된 뒤로, exports 접근 가드가 exports를 미해결 전역 이름 목록에서 찾지 못해 항상 조기 반환했다. 함수 안에서만 읽는 exports.X = … 대입이 tree-shaking으로 지워졌고, 스모크 테스트의 mime-types가 10월 7일부터 TypeError로 실패했다.
  • #5464 — 그래프를 재사용하는 경로에서 tree shaker가 노드를 소스 위치로 최상위 문장에 귀속했다. 위치가 없는 JSX 변환 참조가 빠져, classic JSX의 지역 factory const h가 지워지고 ReferenceError: h is not defined가 났다. AST 간선을 따라 귀속하도록 바꿨다.
  • #5461 — UMD·AMD 외부 import가 같은 이름의 factory 매개변수와 충돌한 것으로 판정돼 React$1 별칭이 생겼다. 3-24에서 #5241 본문을 인용해 적은 CLI 테스트 실패(React.version 대신 React$1 참조)가 이것이다.
  • #5460 — 단일 파일 minify가 helper 이름까지 줄이면서, __asyncGenerator 본문의 지역 변수가 __await의 짧은 이름을 가려 TypeError: Right-hand side of 'instanceof' is not callable이 났다.
  • #5466 — 늦은 이름 결정의 작업용 이름이 원래 이름(_super·_state·_newTarget)과 같아 같은 scope의 소스 바인딩과 충돌했다. ES5 출력의 TypeError와 DuplicateBinding panic이 여기서 나왔다. 아래 절에서 다시 다룬다.
  • #5469 — ES5 generator·async 상태 기계에서 추출된 루프 본문에 strict 블록 함수 선언이 있으면 InvalidScope로 panic했다. 함수 선언의 이름을 함수 자신의 scope가 아니라 선언이 놓인 scope로 추적하게 했다.
  • #5470·#5478 — 매개변수와 같은 이름의 본문 var가 매개변수 값으로 초기화되지 않아 function f(x, h = () => x) { var x; … }가 native의 [7,7] 대신 [null,7]을 냈다.
  • #5472·#5483 — strict 소스의 블록 안 함수 선언이 IIFE·UMD·AMD·__esm 래퍼처럼 sloppy로 실행되는 출력에서 Annex B로 끌어올려져 다른 모듈의 최상위 바인딩을 덮었다. ES2015 이상은 esbuild처럼 블록 맨 위의 let f = function …으로, ES5·RN은 Babel처럼 블록 바인딩의 이름 변경과 모듈 간 deconflict로 고쳤다.

조사 중 추가로 찾은 결함도 같은 체크리스트에 들어갔다. #5476은 의미 정보를 재분석한 뒤에도 이전 단계의 참조 감산량을 다시 적용해, vue 번들을 --minify-syntax로 만들면 @vue/runtime-dom의 최상위 선언 넷이 지워지고 ReferenceError가 나던 문제를 고쳤다. #5479는 코드 분할에서 청크 간 import 이름이 소비 모듈의 미해결 전역 참조를 가로채던 문제와, 동적 import 대상 청크의 동명 export가 SyntaxError로 링크되지 않던 기존 버그를 함께 고쳤다. #5480은 CJS 출력에 무조건 붙던 "use strict"를 번들된 모듈이 모두 strict일 때만 붙이게 했다. sloppy CJS 의존성의 with 문이 SyntaxError를 내던 경우다. ESM 출력에 with가 있으면 경고 ZNTC0800을 낸다.

10월 11일 22시 18분 KST, #5478의 커밋 fcaa86ca7에서 돈 CI는 Lint·Debug Test·Smoke·NAPI & CLI·Integration·E2E·macOS 네이티브 job이 모두 성공했다. darwin-arm64 패키지 스모크 job 하나만 취소돼 실행 전체의 결론은 cancelled로 남았다. #5457 댓글은 이를 Lint·Smoke·NAPI & CLI·Integration·E2E·Debug Test가 모두 통과한, 마지막 성공 이후 첫 실행으로 기록했다.

그리고 CI를 껐다

같은 날 23시 54분 KST의 #5457 댓글은 요청에 따라 CI 워크플로를 비활성화했고 PR은 관리자 병합으로 진행한다고 적었다. 실행 기록도 이와 맞는다. 마지막 CI 실행은 22시 52분 KST에 #5479·#5480 커밋으로 시작됐다가 둘 다 취소됐고, 그 뒤 이 편 마지막 커밋까지는 CI 실행이 없다. fcaa86ca7 뒤의 102커밋은 끝까지 돈 CI로 검증되지 않았고, 각 PR 본문의 로컬 검증만 근거로 남았다. 10월 13일 확인한 워크플로 상태도 disabled_manually다.

#5457은 10월 12일 0시 30분 KST에 닫혔고, 남은 CI 게이트 항목은 #5486으로 옮겨졌다. #5486은 CI 재활성화 시점, main push의 취소 정책, CI 통과 전 자동 병합 금지 여부를 할 일로 두고 열려 있다.

이름은 정말 마지막에

10월 10일의 #4819 작업은 대부분 단일 파일 출력에서 합성 이름을 언제 정하느냐에 관한 것이다. 0시 5분 KST의 #5330은 비압축 단일 파일 변환에서 런타임 helper의 출력 이름을 변환이 끝난 뒤로 미뤘다. 모든 소스·합성 심볼, 미해결 외부 이름, ES5 블록 이름 변경 결과를 예약한 다음 helper의 SymbolId마다 이름을 배정하고, preamble과 호출부가 같은 표를 쓴다. 이어 JSX runtime import(#5331), Stage 3 데코레이터의 _metadata·_classThis·_classDecorators·멤버 배열(#5353부터 #5356까지), generator _state(#5359), ES5 클래스 _super(#5360), 추출된 루프의 _loop·_ret, iterator _step, 객체 super의 _obj, tagged template의 _templateObject(#5375부터 #5379까지)가 같은 방식으로 옮겨졌다. 변환 중에는 __zntc_step 같은 작업용 철자를 쓰고, 완성된 심볼 집합을 본 뒤 _step·_step2를 고른다. direct eval·with가 있는 scope, 번들러, 식별자 minify, 의미 정보 없는 경로는 기존의 조기 결정에 남았다.

옮기면서 실제 출력 결함도 나왔다. #5362는 ES5 번들에서 화살표의 new.target 참조는 _newTarget2, 선언은 _newTarget3이 돼 ReferenceError가 나던 경우를 재현했다. #5363은 React Native 0.80 대상에서 생성된 _newTarget 매개변수가 같은 이름의 사용자 매개변수를 가려 true 11 undefined가 나와야 할 출력이 true [Function: Foo] undefined가 되던 경우를 고쳤다. #5373은 생성 함수 이름을 이름·span·추정 scope로 선언하던 보정 경로를 지웠다. 지우기 전에 298개 fixture × 6개 타깃, 1,788회 변환에서 그 경로가 한 번도 쓰이지 않았음을 계측했다고 적었다.

그런데 이 방식이 적색 CI 원인 중 하나(#5451)를 만들었다. 작업용 이름이 원래 이름과 같아서, 같은 scope에 소스 바인딩 _super·_state가 있으면 철자로 바인딩을 찾는 의미 편집기에서 둘이 충돌했다. 앞서 _newTarget 한 경로만 막으려고 넣은 "이름이 모듈 어디에든 있으면 늦은 결정을 끈다"는 가드(#5365)도 다른 테스트를 깨뜨렸다. 10월 11일 #5466은 작업용 이름을 소스와 충돌하지 않는 철자로 고르고, 원래 이름은 따로 기록해 최종 이름의 기준으로 쓰게 했다. 모듈 단위 가드는 지웠다. 10월 12일에는 lexical this·arguments 캡처도 같은 경로에 들어갔다(#5550·#5551, semantic cache codec v14·v15).

같은 날 아침에는 매개변수 환경도 나뉘었다. #5346은 기본값·구조 분해·rest가 있는 매개변수 목록에 별도 scope를 만들고 함수 본문 scope를 그 아래에 뒀다. 이어 #5347부터 #5351까지 object rest 하강으로 기본값이 본문으로 옮겨질 때 본문 var나 direct eval·with가 그 기본값을 가로채지 않게 했다. #5351은 generator와 화살표 함수를 지원하지 않는 타깃, 매개변수 식 자체의 동적 조회는 아직 보수 경로에 두며, 이때 하강되지 않은 매개변수 문법이 출력에 남을 수 있다고 적었다. 앞의 #5470·#5478이 고친 회귀(#5452)는 이 #5346에서 시작됐다.

재분석 없이 유지하는 범위

제목에 retain이 들어간 PR은 93개로, 전체의 약 40%다. 유형 없이 #4819로 시작하는 두 제목(#5474·#5475)도 같은 작업이다. 3-24와 같은 방식으로, 번들러 사전 처리에서 어떤 모양이면 변환 뒤 의미 분석을 다시 돌리지 않아도 되는지를 PR마다 한 단계씩 넓혔다.

10월 10일 새벽에는 플러그인 변환이 들어갔다. React Refresh(#5335), Emotion(#5336), Reanimated Worklet(#5337부터 #5339까지)이고, styled-components의 CSS prop은 그날 밤 #5381로 이어졌다. 오후의 #5357은 Worklet 안의 new ClassName()이 만드는 class factory closure 항목이 같은 이름의 사용자 closure 변수와 충돌하던 문제를 고쳤다. 10월 11일 새벽에는 ES5 배열·호출·생성자 spread가 들어갔다(#5385부터 #5390까지). #5386은 기존 member-call spread 하강이 receiver를 두 번 평가하던 문제도 함께 고쳤다.

10월 11일 오전 5시 47분부터 11시 55분 사이에는 파생 클래스 생성자를 다뤘다. #5396이 단순 파생 클래스를 허용한 뒤, 이 시간대에 제목에 retain과 super가 함께 들어간 PR만 23개가 들어갔다. super() 하나만 있는 생성자에서 시작해 그 뒤의 this 속성 대입, 지역 var, 조건 분기, return·throw, switch, try, 반복문, 블록 함수 선언까지 넓혔다. 각 PR은 base 생성자가 Proxy를 반환할 때 쓰기 순서나 getter 호출 횟수가 원본과 같은지 실행으로 확인하고, 허용 밖의 모양이 재분석으로 돌아가는지도 대조했다. 이 과정의 #5425는 strict 코드의 블록 함수 선언을 함수 scope에 미리 등록하던 분석기 결함을 명세대로 고쳤다. 그런데 이 올바른 수정이 #5453·#5454 회귀를 드러냈고, 두 이슈는 같은 날 닫혔다.

10월 11일 저녁부터 12일 오후까지는 static field 초기화 식이 대상이었다. 미해결 외부 이름에서 시작하는 member 읽기와 호출(#5477·#5481), optional chaining과 호출 결과 receiver(#5484부터 #5505까지), 호출 인자 안의 쉼표 식과 계산된 key(#5513부터 #5547 사이의 그래프 유지 PR)가 차례로 들어갔다. 이 과정에서 #5506은 ES5 holder.outer?.receiver.fieldValue?.(arg())에서 첫 optional member의 null 검사가 뒤 호출 전체를 감싸지 못해 TypeError가 나던 하강 결함을 고쳤다.

Object 이름에 대한 판정도 좁혀졌다. ES2017 object spread는 Object.assign 호출로 낮아지는데, #5569에 따르면 소스에 Object 바인딩이 있으면 이 호출이 사용자 변수에 연결돼 실행 오류가 났다. 사전 처리 게이트가 object spread를 보게 해 이를 막았다. 반대로 모듈 어딘가에 Object 바인딩이 있다는 이유만으로 재분석하던 거부 조건은, 해당 변환 위치의 lexical scope에서 실제로 보이는지만 따지도록 바꿨다(#5576·#5577·#5579·#5584).

고친 뒤 다시 공격한다 — .name과 --keep-names

10월 12일 새벽부터 오후까지는 함수와 클래스의 .name을 고치는 PR이 이어졌다. 여러 PR이 본문에 앞 PR을 병합한 뒤 적대적으로 다시 검증하다 찾았다고 적었고, native Node 실행 결과와 esbuild·tsc·Babel의 출력을 기준으로 비교했다.

  • #5487·#5492(0시 46분, 1시 37분) — Annex B 수정의 후속이다. switch case 안 함수 선언이 IIFE·UMD 출력에서 다른 모듈의 함수를 덮었고, 블록 함수가 import와 이름이 겹치는 ES5 출력은 __name is not defined로 실패했다.
  • #5515(7시 27분) — 번들 출력은 dev가 아니면 항상 minify 패스를 도는데, 단일 사용 인라인이 const b = () => {}의 익명 함수를 사용 위치로 옮겨 minify 옵션 없이도 b.name이 ""가 됐다.
  • #5517(8시 2분) — --keep-names가 이름 복원 호출을 모듈 끝에 몰아 붙이던 구조를 값이 만들어지는 자리로 옮겼다. --minify --keep-names는 중첩 함수 선언이 있으면 ReferenceError로 실패했다(#5512).
  • #5523(8시 51분) — static name()을 가진 클래스를 --keep-names가 덮어써 TypeError가 났고, 사용자 함수 __name이 keep-names helper에 덮였다.
  • #5529(10시 19분) — 클래스를 낮추는 타깃에서 익명 클래스의 .name이 _a·_Class가 되던 문제(#5519)와 함께, ES2015부터 ES2021 타깃에서 export default class { static s = … }가 panic하던 문제를 고쳤다.
  • #5531·#5534(11시 9분, 11시 57분) — 숫자·BigInt key의 이름 형식, useDefineForClassFields: false의 static 블록, 빈 CLI 인자의 panic을 고쳤다. 48분 뒤의 #5534는 #5531이 만든 회귀(private 접근의 SyntaxError)를 고치고, 필드 초기화 식의 new.target과 화살표 함수 이름도 바로잡았다.
  • #5541(14시 12분) — ES5·React Native 출력에서 클래스 메서드의 .name이 모두 "value", getter가 "get"이 되던 문제를 고쳤다. Object.defineProperty의 value: 속성 이름이 추론됐기 때문이다.
  • #5555(17시 10분) — [k]·[Symbol.iterator]처럼 실행 시점에 정해지는 계산된 key 아래의 익명 함수·클래스 이름을 key를 한 번만 평가해 붙인다(#5527).

#5541은 이름 붙이기의 크기 비용도 쟀다. RN 예제 앱의 minify 출력이 937,662바이트에서 957,412바이트로 2.1% 늘었다. #4819 게이트가 변환기가 만든 이름 있는 함수 식의 scope를 받아들이도록 #5544에서 넓힌 뒤, #5548이 Babel과 같은 value: function m() {} 형태로 바꿔 952,438바이트가 됐다. 이 PR의 제목은 perf지만 본문의 측정값은 출력 크기뿐이다.

낮춘 top-level await와 TypeScript import

10월 12일 오후와 저녁에는 빌드가 성공하고도 실행에서 깨지는 결함이 이어서 고쳐졌다.

#5557(17시 36분)은 --platform=react-native 빌드가 async 함수 안의 { const d = 2; console.log({ d }); }처럼 흔한 shorthand 패턴에서 InvalidScope로 중단되던 문제를 고쳤다. PR은 저장소의 examples/react-native-bare 빌드도 이 크래시로 실패하고 있었다고 적었다. #5560(18시 13분)은 기본값을 낮추지 않는 타깃에서 매개변수 기본값 안의 임시 변수가 함수 본문 var로 선언돼 ReferenceError가 나거나, 클래스 메서드에서는 빌드가 중단되던 문제다.

top-level await를 낮추는 경로에서는 네 PR이 들어갔다.

  • #5568(19시 10분) — ES2017부터 ES2021 타깃 번들에서 최상위 임시 변수가 있으면 모듈 본문이 통째로 사라졌다. 모듈 출력 오류를 삼키던 catch null을 없애 이제 같은 상황은 빌드 실패로 드러난다.
  • #5573(19시 41분) — async를 낮춰야 하는 ES2016 이하에서 export const v = await …가 최상위 yield로 나가 실행 시 SyntaxError였다. 이제 빌드 오류 ZNTC0001로 거부한다.
  • #5578(20시 31분) — 모듈을 async IIFE와 그 뒤 export 선언으로 나누던 방식 때문에 const config = await load(); export function useX() … 같은 패턴이 ReferenceError를 냈다. async를 지원하는 ESM 출력에서는 모듈을 나누지 않고 async __esm factory 안에 그대로 둔다. PR은 새 테스트 48개 중 38개가 이전 바이너리에서 실패했다고 적었다.
  • #5582(21시 3분) — linker가 TypeScript의 "타입으로만 쓰인 import 제거" 판정을 JS에도 적용해, 쓰이지 않거나 값이 인라인된 import의 래핑 모듈 초기화가 빠졌다. CommonJS 의존성은 ES2022에서도 부작용이 사라졌다.

이어 #5589(22시 33분)는 TypeScript에서 바인딩이 모두 타입으로만 쓰였거나 쓰이지 않은 named import를 tsc·esbuild처럼 통째로 제거해 그 모듈을 평가하지 않게 했다. 미사용 TS import라도 부작용 있는 모듈은 유지한다는 기존 테스트(#1913)의 동작을 뒤집은 것으로, PR은 논의 후 tsc 의미론으로 정했다고 적었다. verbatimModuleSyntax에서는 기존처럼 유지한다. 마지막의 #5592(23시 18분)는 emitDecoratorMetadata 출력을 tsc와 맞췄다. constructor(private readonly svc: Service)의 design:paramtypes가 [Object]로 나와 NestJS 같은 DI가 잘못된 토큰을 받던 경우다. PR은 새 테스트 22개 중 20개가 이전 바이너리에서 실패했다고 적었다.

검사는 계속 촘촘해졌다

검사 쪽은 3-24의 방향을 이었다. #5358은 minify 뒤 정체성 감사를 ES5와 ESNext 두 타깃에서 6개 타깃 전체로 넓혀, 298개 fixture × 6개 타깃 × 3개 minify 모드 = 5,364개 변환 결과를 검사하게 했다. #5382는 세 보고서의 0이어야 하는 counter 56개를 하나씩 0→1로 오염시켜 파서가 바로 그 counter를 거부하는지 확인한다. 10월 12일에는 저장된 참조 수와 쓰기 수를 참조 행에서 다시 세어 비교하는 불변식(#5574)과, 정확성 보고서의 진단 필드 16개 전부가 isClean()을 실패시키는지 확인하는 테스트(#5586)가 들어갔다.

로컬에서만 나던 실패도 정리됐다. #5561은 watch 메모리 스트레스 테스트가 Debug 바이너리에서 실패하던 원인을 Debug allocator의 특성으로 확인하고, 누수 판정은 release 바이너리에서만 하게 했다.

남은 것

#4819는 10월 13일 확인 시점에도 열려 있다. 이 구간에 10월 11일 8시 5분과 10월 12일 18시 33분 KST 두 번 닫혔다가 1분 안에 다시 열렸다. 작업 PR들은 여전히 생성자 전수 이관, 통합된 최종 이름 결정, 재분석 제거를 남은 범위로 적는다. ES5/RN TLA 소비자를 promise로 잇는 PR #4631도 열려 있다.

이 구간에 열린 이슈 중 #5486(CI 게이트), #5553(낮춘 클래스 필드의 계산된 key 재평가), #5575(async factory가 없는 TLA 하강의 순서 한계), #5580(TLA 모듈과 형제 모듈의 평가 순서), #5594(export된 const enum), #5595(#5581 후속)가 10월 13일 확인 시점에 열려 있다. TS 타입 재export 문제인 #5583은 이 편 마지막 커밋 시점에 열려 있었고, 범위 밖인 10월 13일 1시 20분 KST에 닫혔다.

main의 대기 중인 changeset 파일은 3-24 마지막 커밋의 73개에서 이 편 마지막 커밋의 114개로 늘었지만, 이 변경을 담은 새 릴리즈는 확인되지 않았다. CI는 꺼진 상태이고, #5486의 첫 할 일은 CI를 다시 켜 첫 main 실행으로 fcaa86ca7 이후 구간을 검증하는 것이다.