/* * **투영이 읽는 이름이 선언에 실재하는가.** * * ── 무엇이 있었나 (2026-08-30) ──────────────────────────────────────────── * 설비 투영이 `m.gs1Id` 를 읽고 있었는데 **선언에 그런 필드가 없었다.** 그래서 그 칸은 일곱 대 모두 * 영원히 비어 있었다. 오류는 나지 않았다 — `?? null` 이 늘 `null` 을 냈을 뿐이다. * * 값은 한 칸 옆에 있었다. 커넥터가 인버터의 하드웨어 식별자를 이미 보내고 있었고, 정체성 칸도 * 있었는데 그 사이에 길이 없었다. 인티그레이션 레인이 「값이 한 칸 옆에 있다」고 알려 와서 찾았다. * * ── 왜 다른 시험이 못 잡았나 ───────────────────────────────────────────── * `projection-reaches-screen` 은 **마스터 → 행 → 화면**을 걷는다. 그 길은 멀쩡했다 — 참조 계층이 * `gs1Id` 를 실어 보내고 있었기 때문이다. 끊긴 것은 **선언 → 행** 쪽이었고, 아무도 그 길을 걷지 * 않았다. * * 이 시험은 이름만 본다. 값이 흐르는지는 저 시험이 보고, 여기서는 **읽는 이름이 실재하는지**만 본다. * 둘이 함께 있어야 「자리는 있고 길이 없다」가 막힌다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' const here = dirname(fileURLToPath(import.meta.url)) const req = createRequire(import.meta.url) const projection = readFileSync(join(here, '..', 'server', 'service', 'twin-model', 'project-structure.ts'), 'utf-8') /** 계약이 배포한 선언 타입 원문 — 이름이 실재하는지 여기서 확인한다. */ const contractTypes = readFileSync(join(dirname(req.resolve('@operato/ops-contract')), '..', 'dist', 'contract.d.ts'), 'utf-8') const domainTypes = readFileSync( join(dirname(req.resolve('@operato/ops-contract')), '..', 'dist', 'domain-definition.d.ts'), 'utf-8' ) const declared = contractTypes + '\n' + domainTypes /** * 투영 안의 변수 이름 → 그 변수가 무엇의 선언인가. * * 이름을 손으로 적는다. 투영이 변수를 바꾸면 이 목록이 낡는데, 그때 아래 「대상이 실재한다」 검사가 * 먼저 빨갛게 난다 — 낡은 채로 초록이 되지 않는다. */ const SUBJECTS: { varName: string; what: string }[] = [ { varName: 'l', what: '자리' }, { varName: 'm', what: '설비' }, { varName: 'o', what: '공정' } ] /** 선언에서 오지 않는 이름 — 투영이 스스로 만드는 값이다. 이유를 함께 적는다. */ const NOT_FROM_DECLARATION: Record = { sourceRef: '투영이 붙인다 — 어느 참조에서 왔나', ingestedAt: '투영이 붙인다 — 언제 채웠나', domain: '테넌트 — 호출부가 준다', instanceId: '어느 트윈인가 — 호출부가 준다' } /** * 그 변수에서 읽는 필드들. * * **형 단언을 지나서 본다** — `(m as any).x` 도 읽는 것이다. 처음에는 `m.x` 만 봤고, 그래서 * 유령 이름을 넣어도 잡지 못했다(2026-08-30). 실제 코드가 그 모양을 쓰고 있었으므로 그 자리를 * 통째로 놓치고 있었다. * * 단언을 쓰는 자리가 특히 위험하다 — 타입 검사가 이미 꺼져 있어서 컴파일러도 안 잡는다. */ function readsOf(varName: string): { field: string; line: number }[] { const out: { field: string; line: number }[] = [] const plain = new RegExp(`(? { for (const re of [plain, cast]) for (const m of text.matchAll(re)) out.push({ field: m[1], line: i + 1 }) }) return out } test('★ 검사할 대상이 실재한다 — 없으면 이 시험은 아무것도 지키지 않는다', () => { assert.ok(declared.length > 10_000, '계약 선언 원문을 못 읽었다') for (const s of SUBJECTS) { assert.ok(readsOf(s.varName).length > 0, `${s.what}(${s.varName}) 를 읽는 자리가 없다 — 투영이 바뀌었다`) } }) test('★ 투영이 읽는 이름이 선언에 실재한다 — 없는 이름을 읽으면 그 칸은 영원히 빈다', () => { const ghosts: string[] = [] for (const s of SUBJECTS) { for (const { field, line } of readsOf(s.varName)) { if (field in NOT_FROM_DECLARATION) continue /* 선언 어딘가에 그 이름이 필드로 있는가 — 타입을 가리지 않고 이름만 본다. */ if (!new RegExp(`\\b${field}\\??:`).test(declared)) { ghosts.push(`project-structure.ts:${line} ${s.what}.${field}`) } } } assert.deepEqual( [...new Set(ghosts)], [], `선언에 없는 이름을 읽는다:\n${[...new Set(ghosts)].join('\n')}\n\n` + '그 칸은 늘 null 이 되고 오류는 나지 않는다. 선언에 그 필드를 열거나, 투영이 읽는 이름을 고쳐라.\n' + '선언에서 오는 값이 아니면 NOT_FROM_DECLARATION 에 이유와 함께 올려라.' ) }) test('선언에서 오지 않는 이름에 이유가 적혀 있다', () => { for (const [name, why] of Object.entries(NOT_FROM_DECLARATION)) { assert.ok(why.trim().length > 5, `${name} 에 이유가 없다 — 이유 없이 올리면 이 시험이 아무것도 지키지 않는다`) } })