From d97f740f7303c11c6240cbf348900e07f42f69c4 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 07:19:29 +0900 Subject: [PATCH 1/5] docs(devlog): record what round3 actually delivered The plan promised a six-branch stack and called every move byte-identical. Delivery converged on two PRs, and three sites changed how state is reached rather than only where it lives. An independent audit found both; 090_outcome.md records them with the verification evidence. --- .../_plan/260915_godfile_round3/000_plan.md | 4 ++ .../260915_godfile_round3/090_outcome.md | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 devlog/_plan/260915_godfile_round3/090_outcome.md diff --git a/devlog/_plan/260915_godfile_round3/000_plan.md b/devlog/_plan/260915_godfile_round3/000_plan.md index 9e8c822bfd..e11066ab60 100644 --- a/devlog/_plan/260915_godfile_round3/000_plan.md +++ b/devlog/_plan/260915_godfile_round3/000_plan.md @@ -26,6 +26,8 @@ ## 작업 단계 지도 +아래 표의 브랜치 열은 실행되지 않았다. 다섯 파일이 서로 겹치지 않아 한 워킹트리에서 동시에 작업했고 결과가 두 개의 PR로 수렴했다. 무엇이 실제로 일어났는지는 [`090_outcome.md`](./090_outcome.md)가 기록한다. 각 decade 문서의 이동 계약과 함정 항목은 그대로 실행됐다. + | 사이클 | 문서 | 대상 | 현재 줄 | 브랜치 | |---|---|---|---|---| | 0 | `000_plan.md` + 010~050 | 로드맵(코드 변경 없음) | — | `codex/m3-l1-roadmap` | @@ -54,3 +56,5 @@ | 오라클·INV | 본문을 텍스트로 읽는 오라클의 읽기 경로 갱신, INV 승계 모듈 지정 | | 보안 | `auth-api.ts`의 credential 이동 PR은 별도 검토 기록 | | 머지 | 6개 PR 전부 MERGED, `dev` 착지 후 회귀 녹색 | + +머지 행은 실제로 2개 PR(#4658 → #4655)로 충족됐다. 나머지 조건은 모두 충족됐고 증거는 `090_outcome.md`에 있다. diff --git a/devlog/_plan/260915_godfile_round3/090_outcome.md b/devlog/_plan/260915_godfile_round3/090_outcome.md new file mode 100644 index 0000000000..c8b227a5db --- /dev/null +++ b/devlog/_plan/260915_godfile_round3/090_outcome.md @@ -0,0 +1,37 @@ +# 090 — 실제로 일어난 일과 계획의 차이 + +이 단위는 목표대로 끝났다. 다섯 파일이 facade 뒤로 분해돼 `origin/dev`(머지 커밋 `09067c586a`)에 있고, 착지 후 trunk 회귀도 성공했다. 다만 계획서가 약속한 전달 형태와 실제가 두 군데 다르고, "순수 이동"이라는 표현이 세 지점에서 정확하지 않다. 독립 감사가 그 둘을 지적했고 이 문서가 기록을 바로잡는다. + +## 전달 형태: 6단 스택이 아니라 2개 PR + +`000_plan.md`의 사이클 표는 파일마다 브랜치를 하나씩 두는 6단 체인(`codex/m3-l2-config` ~ `codex/m3-l6-openai-chat`)을 그렸고 완료 조건에 "6개 PR 전부 MERGED"를 적었다. 실제로는 두 개로 수렴했다. + +| 실제 PR | 브랜치 | base | 내용 | +|---|---|---|---| +| [#4658](https://github.com/lidge-jun/opencodex/pull/4658) | `codex/m3-impl` | `codex/m3-l1-roadmap` | 다섯 파일 분해와 동반 수정 | +| [#4655](https://github.com/lidge-jun/opencodex/pull/4655) | `codex/m3-l1-roadmap` | `dev` | 로드맵 문서 + 위 구현의 trunk 착지 | + +이유는 실행 방식에 있다. 다섯 파일은 서로 겹치지 않아서 한 워킹트리에서 다섯 에이전트가 동시에 작업했고, 그 결과가 한 트리에 함께 쌓였다. 파일별 커밋으로는 나눌 수 있었지만 브랜치로는 나눌 수 없었다. `structure/runtime.md`, `structure/providers/openai-tiers.md` 같은 소유 문서를 세 파일이 함께 고쳤기 때문에, 그 헝크를 브랜치별로 가르면 중간 레이어의 문서가 자기 트리와 어긋난다. + +따라서 각 decade 문서가 적은 브랜치 이름(`010:5`의 `m3-l6-config`, `050:5`의 `m3-l6-adapters-chat`)과 PR 개수(`020` 3개, `030` 9개, `040` 6개)는 실행되지 않은 계획이다. 그 문서들의 이동 계약, 원본 행 범위, 함정 항목은 그대로 유효하고 실제로 그대로 실행됐다. + +## "순수 이동"이 정확하지 않은 세 지점 + +감사가 파사드에서 삭제된 줄을 전수 대조해 찾아냈다. 잘라 붙이기만 한 것이 아니라 접근 방식이 바뀐 곳이 셋이다. 셋 다 동작은 같지만 기록은 정확해야 한다. + +`src/config.ts`의 경고 메모는 원래 `Set.has`와 `Set.add`를 직접 불렀다. 지금은 `src/config/warn-memo.ts`의 접근자를 거친다. Set 선언과 reconcile 본문은 바이트 동일이지만 호출 지점이 달라졌다. 같은 파일의 기본값 병합 인라인 블록은 `src/config/diagnostics.ts`의 `mergeConfigDefaults`로 빠졌고 `typeof` 가드가 하나 늘었다. 핀 세 개와 providers 병합은 같다. + +`src/codex/auth-api.ts`의 quota 시퀀스는 원래 변수를 직접 증감했고 지금은 `src/codex/auth-api/pool-quota-probe.ts`의 접근자 네 개를 거친다. 모듈 스코프 변수를 단일 소유로 유지하려면 다른 방법이 없었다. ESM live binding은 바깥에서 쓸 수 없기 때문이다. + +## 검증 증거 + +- 파사드 export 표면은 분해 전후 동일하다. 감사가 `origin/dev~1`과 `origin/dev`로 독립 재현했다. +- 심볼 일곱 개의 본문을 바이트 비교해 동일함을 확인했다(`withConfigMutationLockSync`, `reconcileConfigWarningMemos`, `isTerminalPoolAuthResponse`, `fetchProviderModelsWithAuth`, `toolsToChatFormatForProvider`, `messagesToChatFormat`, ANTHROPIC 시드 3종). +- 새 리프 39개 중 최대가 1,221줄이다. 순환 import 없음, 상대 import 미해석 0. +- `#4655` exact head `9eb6290367`에서 24 SUCCESS / 2 SKIPPED. 착지 후 trunk 회귀는 run `34899536061`, head `09067c586a`, conclusion success. + +## 보안 기록 + +`000_plan.md`가 `auth-api.ts`의 credential 이동에 "별도 검토 기록"을 요구했다. 그 기록은 [`#4655`의 통합 코멘트](https://github.com/lidge-jun/opencodex/pull/4655#issuecomment-5670986566)에 있다. access·refresh 토큰이 라우트 모듈에 도달하지 않고, Pool/Direct/API-key 조기 반환 술어 두 개가 한 게이트 모듈에 함께 남았으며, 로직 변경 없이 위치만 이동했다는 내용이다. + +자동 리뷰어는 이 PR들을 보지 않았다. CodeRabbit은 base가 기본 브랜치가 아니면 auto review를 건너뛰고, `#4658`의 base는 `codex/m3-l1-roadmap`이었다. 그래서 이 단위의 코드 검토는 hosted CI와 위 독립 감사가 전부다. From 8301dcb900f15ddb94ed8a97185b2127d574adc5 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 07:21:12 +0900 Subject: [PATCH 2/5] docs(devlog): point the round3 phase docs at the outcome record Each decade doc still named a branch chain and PR count that were never executed. A one-line correction at the top of each sends the reader to 090_outcome.md. --- devlog/_plan/260915_godfile_round3/010_phase1_config.md | 3 +++ .../260915_godfile_round3/020_phase2_providers_registry.md | 3 +++ .../_plan/260915_godfile_round3/030_phase3_codex_auth_api.md | 3 +++ .../260915_godfile_round3/040_phase4_catalog_provider_fetch.md | 3 +++ .../260915_godfile_round3/050_phase5_adapters_openai_chat.md | 3 +++ 5 files changed, 15 insertions(+) diff --git a/devlog/_plan/260915_godfile_round3/010_phase1_config.md b/devlog/_plan/260915_godfile_round3/010_phase1_config.md index 23ba826bef..5690230a15 100644 --- a/devlog/_plan/260915_godfile_round3/010_phase1_config.md +++ b/devlog/_plan/260915_godfile_round3/010_phase1_config.md @@ -2,6 +2,9 @@ src/config.ts 4,799줄(기준 트리 ce0ac617da)이 스키마·로드 열화·salvage·잠금·치환 쓰기·라이브 재결합을 한 파일에 들고 있어 래칫 이후에도 2,000줄을 넘긴다. 이 문서는 `devlog/_plan/260914_godfile_round2/050_phase5_config.md`를 대체하는 복붙 가능한 이동 계약이다. 그 라운드가 dev에서 이 파일에 +92줄(#4546/#4624 credentialGroups)을 더했으므로 모든 원본 행 번호를 이 트리에서 다시 잡았다. 구현자는 아래 원본 행을 새 리프로 옮기고 파사드가 기존 export 이름을 그대로 다시보내며, 소비자는 import 경로를 건드리지 않는다. create-only 경로 initializePersistedConfigIfMissing와 치환 경로 saveConfig는 공용 헬퍼로 합치지 않고 잔여 파사드에 함께 남기고, 경고 메모 세 값은 warn-memo 단일 소유 모듈로 먼저 분리하며, configSchema는 키 그룹으로 쪼개지 않는다. PR 순서는 실제 의존(salvage→schema, diagnostics→salvage/load-degrade, live-reconcile→persist)을 따라 warn-memo·독립 잎 → schema → salvage+load-degrade → mutation-lock+persist-unlocked+diagnostics → live-reconcile로 고정했다. +> 전달 형태 정정: 이 문서가 적은 브랜치 이름과 PR 개수는 실행되지 않았다. 다섯 파일이 한 워킹트리에서 동시에 작업돼 두 개의 PR로 수렴했다. 이동 계약과 함정 항목은 그대로 실행됐다. 실제 전달은 [090_outcome.md](./090_outcome.md) 를 보라. + + 브랜치 `codex/m3-l6-config`, base는 라운드3 체인의 직전 링크(라운드3 000_plan 확정 시 따름). 순수 이동, 동작 변경 없음. 로컬 스위트·typecheck·build는 이 단위 금지(hosted CI). 새 테스트 파일을 만들지 않으므로 layout.json과 test-layout-expected.json은 등록하지 않는다. 기준 트리 ce0ac617da(origin/dev ce0ac617da), 파일 4,799줄 실측. 열린 PR 충돌은 순서에서 제외한다. ## 260914 050 대비 재계측 (dev +92줄의 정체) diff --git a/devlog/_plan/260915_godfile_round3/020_phase2_providers_registry.md b/devlog/_plan/260915_godfile_round3/020_phase2_providers_registry.md index 75deeef539..36ae8be021 100644 --- a/devlog/_plan/260915_godfile_round3/020_phase2_providers_registry.md +++ b/devlog/_plan/260915_godfile_round3/020_phase2_providers_registry.md @@ -2,6 +2,9 @@ 이 문서는 `src/providers/registry.ts`(3,744줄)를 facade 보존 순수 이동으로 네 개의 리프(`registry/types.ts`, `registry/model-seeds.ts`, `registry/entries-core.ts`, `registry/entries-extended.ts`)와 잔여 facade로 나누는 계약이다. 이 파일은 로직 5.4%(203줄)와 provider 엔트리 93개(배열 본문 2,241줄), 공유 시드 상수(906줄), 타입(349줄)으로 이뤄져 있고 모듈 스코프 가변 바인딩이 0개다. 분해 후에도 소비자 52곳은 기존 facade 경로를 그대로 import하고, 배열 순서와 엔트리 객체 아이덴티티는 원본과 동일하게 유지된다. 단일 어댑터 생성 권한은 `src/adapters/registry.ts`에 그대로 두며 이 단위는 그 파일을 건드리지 않는다. +> 전달 형태 정정: 이 문서가 적은 브랜치 이름과 PR 개수는 실행되지 않았다. 다섯 파일이 한 워킹트리에서 동시에 작업돼 두 개의 PR로 수렴했다. 이동 계약과 함정 항목은 그대로 실행됐다. 실제 전달은 [090_outcome.md](./090_outcome.md) 를 보라. + + 로프 위치: 라운드 lane의 phase 2. 브랜치는 phase 안에서 3개로 쌓는다 — `codex/m3-l2-registry-types` → `codex/m3-l2-registry-seeds` → `codex/m3-l2-registry-entries`. 최하단 base는 phase 1(010 문서) head이고 lane bottom은 `codex/m3-l1-roadmap`(origin/dev `ce0ac617da` 기준)이다. 010 문서가 lane 명명과 skip-ci 정책을 소유하며 이 문서와 충돌하면 000/010을 따른다. 로컬 install/build/typecheck/suite는 NOT RUN이고 모든 검증은 hosted CI(레인 tip exact-head)다. 새 테스트 파일을 만들지 않으므로 `scripts/test-layout/layout.json`과 `tests/fixtures/test-layout-expected.json`은 등록하지 않는다. ## 단일 생성 권한 계약 (실측 근거) diff --git a/devlog/_plan/260915_godfile_round3/030_phase3_codex_auth_api.md b/devlog/_plan/260915_godfile_round3/030_phase3_codex_auth_api.md index 6c13aa0b68..8d6f027d47 100644 --- a/devlog/_plan/260915_godfile_round3/030_phase3_codex_auth_api.md +++ b/devlog/_plan/260915_godfile_round3/030_phase3_codex_auth_api.md @@ -2,6 +2,9 @@ 이 단위는 facade 보존 순수 이동으로 `src/codex/auth-api.ts`(3,134줄, 실측 HEAD `ce0ac617da`)를 `src/codex/auth-api/` 아래 10개 리프 모듈로 나누고, 원래 경로는 전량 re-export facade로 남겨 소비자 import를 바꾸지 않는다. 최대 함수 `handleCodexAuthAPI`(2217-3134, 918줄)는 22개 경로 가드로 23개 (method, path) 관리 라우트를 디스패치하며(`/api/codex-auth/pool-strategy` 가드 하나가 PUT과 PATCH 두 쌍을 등록한다), 분해 후 이 함수는 서비스 모듈 호출로만 구성된다. 이 문서의 계약은 보안 경계다. accessToken/refreshToken은 main-probe·pool-probe·reset-credit·login-flow 네 리프 안에만 존재하고 라우트 모듈과 facade를 통과하지 않으며, Pool/Direct/API-key 조기 반환 술어 두 곳(1699-1702, 1834-1839)은 한 모듈에 함께 둔다. 9개 PR 중 6개는 AGENTS.md 심사 경계(인증·credential·OAuth 표면)에 따라 보안 검토가 필요하고 나머지 3개는 순수 이동임을 각 PR 표기로 명시한다. +> 전달 형태 정정: 이 문서가 적은 브랜치 이름과 PR 개수는 실행되지 않았다. 다섯 파일이 한 워킹트리에서 동시에 작업돼 두 개의 PR로 수렴했다. 이동 계약과 함정 항목은 그대로 실행됐다. 실제 전달은 [090_outcome.md](./090_outcome.md) 를 보라. + + 로프 위치: 레인·브랜치 배치는 `000_plan.md`가 소유하며 이 문서는 파일 분해 계약만 고정한다. 모든 원본 행 번호는 브랜치 `codex/m3-l1-roadmap` HEAD `ce0ac617da`(origin/dev와 동일) 실측값이다. 로컬 install/build/typecheck/suite는 NOT RUN이고 검증은 hosted CI(레인 tip exact-head)다. 새 테스트 파일을 만들지 않으므로 `scripts/test-layout/layout.json:427`의 기존 `codex-auth-api.test.ts` 항목과 `tests/fixtures/test-layout-expected.json` 등록은 변경하지 않는다. ## 범위와 비범위 diff --git a/devlog/_plan/260915_godfile_round3/040_phase4_catalog_provider_fetch.md b/devlog/_plan/260915_godfile_round3/040_phase4_catalog_provider_fetch.md index a91e39ed60..6c952e292f 100644 --- a/devlog/_plan/260915_godfile_round3/040_phase4_catalog_provider_fetch.md +++ b/devlog/_plan/260915_godfile_round3/040_phase4_catalog_provider_fetch.md @@ -2,6 +2,9 @@ provider-fetch.ts 2,944줄은 카탈로그의 "살아있는 발견" 전부 — gather single-flight, 인증 캡처, 모델 API 파싱, 콤보 합성, 설정 힌트 병합 — 를 한 파일에 쌓아 올린 파일이다. 이 문서는 그것을 상태 소유권이 겹치지 않는 6개 리프로 나눈 원본 행 범위, 예상 줄 수, PR별 write set, 재수출, 주석 오라클 패치를 복붙 실행 가능하게 고정한다. 실행자는 이 순서대로만 옮기고, 소비자(convergence, retained-sync, build-entries, management 서버, CLI)는 facade 경로를 유지하므로 아무것도 바뀌지 않으며, 마지막 PR에서 provider-fetch.ts는 sync.ts 52줄 선례와 같은 named re-export 전용 파사드가 된다. +> 전달 형태 정정: 이 문서가 적은 브랜치 이름과 PR 개수는 실행되지 않았다. 다섯 파일이 한 워킹트리에서 동시에 작업돼 두 개의 PR로 수렴했다. 이동 계약과 함정 항목은 그대로 실행됐다. 실제 전달은 [090_outcome.md](./090_outcome.md) 를 보라. + + 기준 트리: 작업 디렉터리 `/Users/jun/.codex/worktrees/5880/opencodex`, 브랜치 `codex/m3-l1-roadmap`, `origin/dev` `ce0ac617da`, HEAD `ce0ac617da`. 이 문서의 모든 행 번호는 그 HEAD에서 `wc -l`과 `rg -n`으로 실측한 값이다. PR1 base는 L4 체인 tip(`codex/m3-l4-auth-api`)이고 PR6 head가 사이클 4 tip(`codex/m3-l5-provider-fetch`)이다. 앞선 PR이 줄을 지운 뒤에는 sed 범위가 아니라 심볼 표가 권위다. 로컬 install/build/test는 하지 않는다. 로컬 검증은 `/tmp/m3_verify.ts`(`000_plan.md` 정의) 하나이고 나머지는 hosted exact-head CI다. 순수 이동. 동작 변경 금지. 원본 경로 facade 재수출 필수. diff --git a/devlog/_plan/260915_godfile_round3/050_phase5_adapters_openai_chat.md b/devlog/_plan/260915_godfile_round3/050_phase5_adapters_openai_chat.md index 6d864f70ea..b02025a5f8 100644 --- a/devlog/_plan/260915_godfile_round3/050_phase5_adapters_openai_chat.md +++ b/devlog/_plan/260915_godfile_round3/050_phase5_adapters_openai_chat.md @@ -2,6 +2,9 @@ src/adapters/openai-chat.ts 2,234줄이 요청 직렬화·passthrough·오류 본문 추출·SSE 스트림 해석·도구 스키마 정규화(zen/azure/moonshot/volcengine/xai)·메시지 변환을 한 파일에 들고 있어 래칫 기준 1,999줄을 넘긴다. 이 문서는 그 파일을 4개 PR로 줄이는 복붙 가능한 이동 계약이다. 구현자는 아래 원본 행 범위를 새 리프로 옮기고, 파사드는 createOpenAIChatAdapter 본문과 현행 공개 export 4종을 그대로 유지하며, 소비자(registry·mimo-free·openai-responses·chat-native·src/index·lab executor)는 import 경로를 건드리지 않는다. 상태는 오직 파사드 팩토리 클로저의 lastRequestedModelId 한 개뿐이고, 이동은 순수 잘라 붙이기다. translator budget 위치 인자 계약과 reasoning-replay 소스 오라클 승계, 라운드 2에서 CI가 실제로 잡은 5종 결함(리프 미export·파사드 로컬 import 누락·타입 오import·정의 소실·상대 경로 깊이 오류)에 대한 예방 항목을 포함한다. +> 전달 형태 정정: 이 문서가 적은 브랜치 이름과 PR 개수는 실행되지 않았다. 다섯 파일이 한 워킹트리에서 동시에 작업돼 두 개의 PR로 수렴했다. 이동 계약과 함정 항목은 그대로 실행됐다. 실제 전달은 [090_outcome.md](./090_outcome.md) 를 보라. + + 브랜치는 round3 레인 패턴을 따르는 `codex/m3-l6-adapters-chat`(round2 기준 phase5=여섯 번째 링크. 레인 명칭 확정은 round3 000_plan 소유이며, 확정되면 그 이름을 따른다). base는 round3 레인에서 바로 앞 링크의 head이고, 레인 밖 기준 트리는 origin/dev ce0ac617da이다(이 문서의 실측 HEAD와 동일 커밋). 순수 이동, 동작 변경 없음. 로컬 스위트·typecheck·build·install은 이 단위 금지(hosted CI). 새 테스트 파일을 만들지 않으므로 layout.json과 tests/fixtures/test-layout-expected.json은 등록하지 않는다. 기준 파일 2,234줄. ## 실측 기록 (이 트리, ce0ac617da) From f5a8a440949a07ced39572a82a6d4600f92149dd Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 07:25:26 +0900 Subject: [PATCH 3/5] test(ci-workflows): assert every relative import under src resolves Two consecutive facade-extraction rounds shipped a leaf one directory deeper than the file it was cut from and kept the original specifier. Neither was visible to a parser, an export-surface comparison, or a reviewer reading the diff, because the specifier is well-formed and only resolution fails. This guard reads the same resolver the boundary guards use and drives red on a known miss. --- .../repo-import-resolution.test.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/ci-workflows/repo-import-resolution.test.ts diff --git a/tests/ci-workflows/repo-import-resolution.test.ts b/tests/ci-workflows/repo-import-resolution.test.ts new file mode 100644 index 0000000000..da97476549 --- /dev/null +++ b/tests/ci-workflows/repo-import-resolution.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +/** + * Every relative import specifier under src/ must resolve to a file that exists. + * + * This is not hypothetical. Two consecutive facade-extraction rounds shipped a leaf one + * directory deeper than the file it was cut from and carried the original specifier with + * it. In the first, src/codex/routing/active-account.ts kept "../config", which resolves + * to src/codex/config -- a path that does not exist -- and every test shard that loaded + * the routing graph died at import time. In the second, an inline import("./types") inside + * src/config/schema/config-schema.ts pointed at src/config/schema/types for the same + * reason. Neither was visible to a parser, to an export-surface comparison, or to a + * reviewer reading the diff, because the specifier is well-formed; only resolution fails. + * + * Resolution mechanics are borrowed from tests/helpers/import-graph.ts rather than + * restated. That module exists so a second guard is not a third copy of the matcher, and a + * copy cannot fail when the original drifts. + */ +import { repoRoot, resolveSpec, runtimeImportEdges, slashed } from "../helpers/import-graph"; + +/** + * Type-only edges are invisible to runtimeImportEdges by design: it answers "what does + * loading this file pull in", and a type import pulls in nothing. A broken one is still a + * defect -- it fails typecheck rather than the runtime -- and it is the same authoring + * mistake, so this guard covers both and keeps the two patterns separate rather than + * loosening the shared one. + */ +const TYPE_EDGE_PATTERN = + "^\\s*import\\s+type\\s+[^;]*?from\\s+[\"']([^\"']+)[\"']|^\\s*export\\s+type\\s+[^;]*?from\\s+[\"']([^\"']+)[\"']"; + +function typeImportSpecs(source: string): string[] { + const pattern = new RegExp(TYPE_EDGE_PATTERN, "gm"); + const specs: string[] = []; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + const spec = match[1] ?? match[2]; + if (spec) specs.push(spec); + } + return specs; +} + +/** + * resolveSpec answers the runtime question and tries .ts, index.ts, .mts and .mjs. A + * specifier that already carries its extension -- the .json data snapshots under + * src/codex/catalog, the .mjs launch policy under src/update -- is resolved by existence + * instead. Both are real edges; only the spelling differs. + * + * TypeScript's ESM convention spells a sibling .ts module as "./wire.js": the specifier + * names the emitted file, not the source. src/adapters/devin and src/oauth/devin are + * written that way, so the .js -> .ts rewrite is part of resolution here rather than a + * tolerated exception. Without it this guard would report 23 healthy edges as broken, + * which is the way a guard gets disabled. + */ +function resolvesFrom(spec: string, absoluteFile: string): boolean { + if (resolveSpec(spec, absoluteFile) !== null) return true; + const literal = resolve(dirname(absoluteFile), spec); + if (existsSync(literal)) return true; + const asSource = literal.replace(/\.js$/, ".ts").replace(/\.mjs$/, ".mts"); + return asSource !== literal && existsSync(asSource); +} + +/** + * src/ only, and that boundary was measured rather than assumed. + * + * Extending the scan to tests/ and scripts/ produced 59 offenders, all false. A source + * oracle spells a production path inside a string it hands to a spawned child -- the + * literal "./src/config.ts" appears three times in one test that never imports it -- and a + * seam declaration lists "../quota/reset-observer" as data for a boundary check. A static + * matcher cannot tell those from an import, and a guard that cries wolf 59 times is a + * guard somebody deletes. Under src/ a relative specifier in import position is an import. + */ +const SCANNED_ROOTS = ["src"] as const; + +function trackedSourceFiles(): string[] { + const listed = Bun.spawnSync(["git", "ls-files", ...SCANNED_ROOTS], { cwd: repoRoot }); + if (listed.exitCode !== 0) { + throw new Error("git ls-files failed: " + new TextDecoder().decode(listed.stderr)); + } + return new TextDecoder() + .decode(listed.stdout) + .split("\n") + .map(line => line.trim()) + .filter(line => line.endsWith(".ts") || line.endsWith(".tsx")); +} + +describe("relative import resolution", () => { + test("the resolver reports a specifier that points at nothing", () => { + // Driven red on purpose: the offender list is only trustworthy if a miss is a miss. + // src/config.ts exists, src/codex/config.ts does not -- exactly the round-one defect. + const from = resolve(repoRoot, "src/codex/routing/active-account.ts"); + expect(resolvesFrom("../../config", from)).toBe(true); + expect(resolvesFrom("../config", from)).toBe(false); + }); + + test("every relative specifier under src/ resolves", () => { + const offenders: string[] = []; + const files = trackedSourceFiles(); + for (const file of files) { + const absolute = resolve(repoRoot, file); + const source = readFileSync(absolute, "utf8"); + const specs = [ + ...runtimeImportEdges(source).map(edge => edge.spec), + ...typeImportSpecs(source), + ]; + for (const spec of specs) { + if (!spec.startsWith(".")) continue; + if (resolvesFrom(spec, absolute)) continue; + offenders.push(slashed(file) + " -> " + spec); + } + } + // An empty tree would also produce an empty offender list, so the scan is proven + // non-vacuous before its result is trusted. + expect(files.length).toBeGreaterThan(500); + expect(offenders).toEqual([]); + }); +}); From 0eab3851a5aa30c51a345fdd20e2cfe8fac74f54 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 07:27:30 +0900 Subject: [PATCH 4/5] test(ci-workflows): extend the import-resolution guard to the dashboard gui/src is production code that moves for the same reasons as src, and the shared resolver never tries .tsx because the proxy runtime has no JSX. Adding that candidate turned 346 apparent offenders into zero and put the extension list where it belongs: the helper states the runtime rule, each guard states the surface it scans. --- .../ci-workflows/repo-import-resolution.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/ci-workflows/repo-import-resolution.test.ts b/tests/ci-workflows/repo-import-resolution.test.ts index da97476549..8eef586b06 100644 --- a/tests/ci-workflows/repo-import-resolution.test.ts +++ b/tests/ci-workflows/repo-import-resolution.test.ts @@ -52,26 +52,35 @@ function typeImportSpecs(source: string): string[] { * written that way, so the .js -> .ts rewrite is part of resolution here rather than a * tolerated exception. Without it this guard would report 23 healthy edges as broken, * which is the way a guard gets disabled. + * + * resolveSpec answers for the proxy runtime, which has no JSX, so it never tries .tsx. The + * dashboard is half .tsx and every one of its component specifiers looked broken until + * that candidate was added -- 346 of them. The extension list belongs to the caller for + * exactly this reason: the shared helper states the runtime rule and each guard states the + * surface it is scanning. */ function resolvesFrom(spec: string, absoluteFile: string): boolean { if (resolveSpec(spec, absoluteFile) !== null) return true; const literal = resolve(dirname(absoluteFile), spec); if (existsSync(literal)) return true; + if (existsSync(literal + ".tsx")) return true; + if (existsSync(resolve(literal, "index.tsx"))) return true; const asSource = literal.replace(/\.js$/, ".ts").replace(/\.mjs$/, ".mts"); return asSource !== literal && existsSync(asSource); } /** - * src/ only, and that boundary was measured rather than assumed. + * src/ and gui/src, and that boundary was measured rather than assumed. * * Extending the scan to tests/ and scripts/ produced 59 offenders, all false. A source * oracle spells a production path inside a string it hands to a spawned child -- the * literal "./src/config.ts" appears three times in one test that never imports it -- and a * seam declaration lists "../quota/reset-observer" as data for a boundary check. A static * matcher cannot tell those from an import, and a guard that cries wolf 59 times is a - * guard somebody deletes. Under src/ a relative specifier in import position is an import. + * guard somebody deletes. Under src/ and gui/src a relative specifier in import position + * is an import, and the dashboard is production code that moves for the same reasons. */ -const SCANNED_ROOTS = ["src"] as const; +const SCANNED_ROOTS = ["src", "gui/src"] as const; function trackedSourceFiles(): string[] { const listed = Bun.spawnSync(["git", "ls-files", ...SCANNED_ROOTS], { cwd: repoRoot }); @@ -94,7 +103,7 @@ describe("relative import resolution", () => { expect(resolvesFrom("../config", from)).toBe(false); }); - test("every relative specifier under src/ resolves", () => { + test("every relative specifier under the scanned production roots resolves", () => { const offenders: string[] = []; const files = trackedSourceFiles(); for (const file of files) { From ca00b7e33e2bd4d862d75506c44d01557da3a021 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 07:28:22 +0900 Subject: [PATCH 5/5] docs(devlog): record the guard the round3 audit produced Both rounds broke CI the same way and the repository had no check for it. The outcome record now says what the guard covers, why tests/ and scripts/ are out of scope, and why the extension list belongs to the caller. --- devlog/_plan/260915_godfile_round3/090_outcome.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/devlog/_plan/260915_godfile_round3/090_outcome.md b/devlog/_plan/260915_godfile_round3/090_outcome.md index c8b227a5db..e703b2fba7 100644 --- a/devlog/_plan/260915_godfile_round3/090_outcome.md +++ b/devlog/_plan/260915_godfile_round3/090_outcome.md @@ -35,3 +35,11 @@ `000_plan.md`가 `auth-api.ts`의 credential 이동에 "별도 검토 기록"을 요구했다. 그 기록은 [`#4655`의 통합 코멘트](https://github.com/lidge-jun/opencodex/pull/4655#issuecomment-5670986566)에 있다. access·refresh 토큰이 라우트 모듈에 도달하지 않고, Pool/Direct/API-key 조기 반환 술어 두 개가 한 게이트 모듈에 함께 남았으며, 로직 변경 없이 위치만 이동했다는 내용이다. 자동 리뷰어는 이 PR들을 보지 않았다. CodeRabbit은 base가 기본 브랜치가 아니면 auto review를 건너뛰고, `#4658`의 base는 `codex/m3-l1-roadmap`이었다. 그래서 이 단위의 코드 검토는 hosted CI와 위 독립 감사가 전부다. + +## 감사에서 나온 산출물 + +이 라운드와 직전 라운드가 같은 결함으로 CI를 깼다. 리프가 원본보다 한 단계 깊어졌는데 상대 경로를 그대로 들고 간 것이다. 1라운드에서는 `src/codex/routing/active-account.ts`의 `../config`가 존재하지 않는 `src/codex/config`로 해석돼 routing 그래프를 로드하는 테스트 샤드가 전부 import 시점에 죽었고, 2라운드에서는 `src/config/schema/config-schema.ts`의 인라인 `import("./types")`가 같은 이유로 어긋났다. 둘 다 파서에도, export 표면 대조에도, diff를 읽는 사람에게도 보이지 않는다. 지정자 자체는 문법적으로 멀쩡하고 해석만 실패하기 때문이다. + +저장소에 그걸 막는 검사가 없어서 `tests/ci-workflows/repo-import-resolution.test.ts`를 만들었다. `src/`와 `gui/src`의 모든 상대 지정자를 전수 해석해 어긋난 것을 경로와 함께 노출하고, 알려진 miss로 적색 구동해 빈 목록이 무의미하지 않음을 증명한다. 해석 기계는 `tests/helpers/import-graph.ts`의 `resolveSpec`을 재사용한다. 그 파일이 스스로 적어둔 이유 그대로다 — 사본은 원본이 흔들릴 때 같이 실패하지 못한다. + +범위는 재어서 정했다. `tests/`와 `scripts/`까지 넓히자 59건이 잡혔는데 전부 오탐이었다. 소스 오라클이 자식 프로세스에 넘기는 문자열 안에 프로덕션 경로를 적어두기 때문이다. 한 테스트에 `"./src/config.ts"`가 세 번 나오는데 그 파일은 그걸 import하지 않는다. 정적 매처는 그 둘을 구별할 수 없고, 59번 잘못 우는 가드는 누군가 지우는 가드다. `gui/src`를 넣을 때는 반대 방향으로 346건이 나왔는데, 공용 해석기가 `.tsx`를 시도하지 않아서였다. 프록시 런타임에는 JSX가 없으니 런타임 규칙으로는 맞다. 확장자 후보는 표면을 아는 호출자가 정하는 게 맞다는 뜻이다.