/* * 커널 선택 가드 — **모르는 종류로는 뜨지 않는다.** * * ── 무엇이 있었나 (2026-08-08) ─────────────────────────────────────────────── * 커널을 고르는 자리 셋이 모두 이랬다: * * const Kernel = KERNELS[reg.kind ?? 'wms'] ?? WmsKernel * * 폴백이 둘 겹쳐 있었다. ①은 컬럼이 비었을 때, ②는 표에 없는 값일 때. 그리고 `kind` 는 * 검증 없는 자유 문자열이다(`@Arg('kind') kind: string` — enum 도 화이트리스트도 없다). * 그래서 오타 하나·대소문자 하나로 **야드/생산 트윈이 오류 없이 창고 커널로 돌았다.** * 오류가 없으니 화면에는 트윈이 정상으로 보이고, 안에서 도는 규칙만 다른 도메인의 것이다. * 예측 경로가 가장 나빴다 — 야드의 미래를 창고 규칙으로 굴려 놓고 숫자만 뜬다. * * ── 왜 검사로 남기나 ──────────────────────────────────────────────────────── * 자리를 고치는 것만으로는 부족하다. 커널 종류가 늘 때(다음은 TMS 든 무엇이든) 새 자리가 * 생기고, `?? WmsKernel` 은 "안전한 기본값" 처럼 보여서 다시 자란다. 실제로 세 자리에 * 똑같이 자라 있었다. 틀린 공장을 알리지 않고 띄우는 것보다 뜨지 않는 편이 낫다. * * 이 검사가 막는 것은 **표를 밖에서 직접 색인하는 것**이다. 종류→커널 해소는 `kernelFor` * 하나만 한다. 새 자리를 쓸 때 이 검사가 걸리면, 폴백을 붙이지 말고 `kernelFor` 를 부를 것. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { guardRoots } from './guard-roots.ts' const ENGINE = new URL('../server/engine/twin-engine.ts', import.meta.url).pathname const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutation.ts', import.meta.url).pathname /* * 종류 폴백은 **한 패키지에 갇혀 있지 않다.** 처음에 twin-engine 두 자리만 고쳤더니, 라이브 미러를 * 띄우는 호출자 넷(operato-twin 의 벤치·부하시험·프리셋 프로비저닝, headless 의 reference-live)이 * `det.kind ?? 'wms'` 로 **엔진이 오류를 내기 전에 먼저 값을 정하고** 있었다. 커넥터 spec 도 마찬가지였다. * 그래서 두 패키지의 서버 코드를 함께 훑는다. */ const ROOTS = guardRoots(import.meta.url, ['../server']) /** * 코드만 본다 — 주석은 **과거를 설명하느라 옛 코드를 그대로 인용한다**(바로 위 머리말이 그렇다). * 줄 단위로 `*`·`//` 만 걸러내면 여러 줄 주석의 둘째 줄부터 새어 나오므로 블록을 통째로 지운다. */ function codeOf(path: string): string[] { return readFileSync(path, 'utf-8') .replace(/\/\*[\s\S]*?\*\//g, '') // 블록 주석 .split('\n') .map(l => l.replace(/\/\/.*$/, '')) // 줄 주석 .filter(l => l.trim()) } test('커널 폴백이 없다 — 모르는 종류는 창고로 떨어지지 않는다', () => { const offenders = codeOf(ENGINE).filter(l => /\?\?\s*WmsKernel|\|\|\s*WmsKernel/.test(l)) assert.deepEqual(offenders, [], '커널 선택에 폴백이 다시 생겼다 — kernelFor 가 오류를 내게 할 것') }) test('종류→커널 해소는 kernelFor 한 곳만 한다', () => { /* 표를 직접 색인하는 줄. 정의부(`const KERNELS: Record…`)와 kernelFor 내부는 제외. */ const indexing = codeOf(ENGINE).filter(l => /KERNELS\[/.test(l) && !/const K = KERNELS\[kind\]/.test(l)) assert.deepEqual(indexing, [], 'KERNELS 를 밖에서 색인하고 있다 — kernelFor(kind) 를 쓸 것') }) test('kernelFor 는 모르는 종류에 오류를 낸다 — 알리지 않고 고르지 않는다', () => { const src = readFileSync(ENGINE, 'utf-8') const fn = src.slice(src.indexOf('function kernelFor')) assert.match(fn.slice(0, 300), /throw new Error/, 'kernelFor 가 오류를 내지 않으면 이 계약이 무너진다') }) test("board 를 주는 기동은 kind 를 요구한다 — `kind ?? 'wms'` 로 메우지 않는다", () => { const offenders = codeOf(MUTATION).filter(l => /kind\s*\?\?\s*['"]/.test(l)) assert.deepEqual(offenders, [], '종류를 빠뜨린 프로비저닝이 다시 창고로 기동한다') }) /** 두 패키지의 서버 .ts 전부. */ function serverFiles(): string[] { const out: string[] = [] const walk = (dir: string) => { for (const e of readdirSync(dir)) { const p = join(dir, e) if (statSync(p).isDirectory()) walk(p) else if (p.endsWith('.ts') && !p.endsWith('.d.ts')) out.push(p) } } for (const r of ROOTS) walk(r) return out } test('어느 서버 코드도 커널 종류를 리터럴로 메우지 않는다 — 호출자가 엔진의 검증을 앞질렀다', () => { /* 예시 JSON 안의 `system: 'wms'` 는 필드를 보여 주는 것이라 대상이 아니다 — 폴백 연산자만 본다. */ const offenders: string[] = [] for (const f of serverFiles()) { codeOf(f).forEach(l => { if (/(\?\?|\|\|)\s*['"](wms|yms|mes)['"]/.test(l)) offenders.push(`${f.split('/packages/')[1]}: ${l.trim().slice(0, 90)}`) }) } assert.deepEqual(offenders, [], '종류 폴백이 다시 생겼다 — 선언이 없으면 메우지 말고 던질 것') })