/* * ★ 구현했는데 선언하지 않은 능력을 아무도 말해 주지 않았다. * * ── 무엇이 났나 (2026-08-25) ──────────────────────────────────────────────── * 능력 판정은 **한 방향만** 본다. 선언했는데 구현이 없는 것은 걸러 내고, 구현했는데 선언이 없는 것은 * 들어온 것이 없다. 그래서 태양광 발전소를 붙이던 사람이 이렇게 막혔다. * * 커넥터가 openLiveFeed 를 구현했다 · capabilities: ['live'] 를 잊었다 * → 연결 화면이 「이 원본은 라이브를 못 낸다」로 읽는다 * → 재기동 정책을 정할 수 없어 트윈 생성이 거절된다 * → 화면에 보이는 것은 「재기동 정책이 없습니다」뿐이다 * * 원인을 찾는 데 코드를 뒤져야 했다. 지금 저장소에 같은 상태인 커넥터가 일곱이다. * * ── 막지 않고 알린다 ──────────────────────────────────────────────────────── * 등록에서 던지면 커넥터 하나 때문에 호스트가 못 뜬다. 만들다 만 커넥터를 들고 있는 사람이 아무것도 * 못 하게 된다. 이 어긋남은 고칠 수 있는 것이지 위험한 것이 아니다. */ import { test } from 'node:test' import assert from 'node:assert/strict' /* `twinWarn` 은 `console.warn` 으로 나간다 — 모듈을 바꿔치지 않고 그 자리에서 받는다. */ const warnings: string[] = [] const originalWarn = console.warn console.warn = (...args: unknown[]) => { warnings.push(args.join(' ')) } process.on('exit', () => (console.warn = originalWarn)) const { registerAdapterType, capabilitiesOf } = await import('../server/service/reference/reference-adapter.js') const adapter = (over: Record = {}) => ({ type: `probe-${Math.round(performance.now() * 1000)}-${warnings.length}`, async fetchMaster() { return {} as any }, ...over }) as any test('★ 라이브를 구현하고 선언하지 않으면 말해 준다 — 이것이 없어서 화면이 다른 말을 했다', () => { const before = warnings.length const a = adapter({ openLiveFeed: () => () => {} }) registerAdapterType(a) const said = warnings.slice(before).join('\n') assert.ok(said.includes(a.type), '어느 커넥터인지 말한다') assert.ok(said.includes('live'), '무엇이 빠졌는지 말한다') assert.ok(said.includes('capabilities'), '무엇을 하면 되는지 말한다') /* 그리고 지금 화면이 어떻게 읽는지도 말한다 — 사람이 겪은 증상과 이어져야 원인으로 읽힌다. */ assert.ok(/live twin cannot be created/.test(said)) }) test('★ 구동을 구현하고 선언하지 않아도 말해 준다 — 같은 규율이다', () => { const before = warnings.length const a = adapter({ control: async () => ({ ok: true }) }) registerAdapterType(a) assert.ok(warnings.slice(before).join('\n').includes('control')) }) test('★ 조치를 구현하고 선언하지 않아도 말해 준다 — 그 이름이 없어서 오늘 하루가 갔다', () => { /* * 이 자리에 `actuate` 가 없었다. 능력 축이 `live`·`control` 둘뿐이라 커넥터는 조치를 **선언할 * 방법이 없었고**, 등록 검사도 그것을 물을 수 없었다. MES 커넥터는 그 사이에 `actuate` 를 다 * 만들어 두었는데, 넘기는 자리는 「구현이 없습니다」로 답했다. * * 그 문장이 그럴듯해서 설계 문서와 다른 레인에까지 사실로 옮겨졌다. 오류가 아니라 **정직해 보이는 * 거절**이 원인이었다. */ const before = warnings.length const a = adapter({ actuate: async () => ({ ok: true }) }) registerAdapterType(a) const said = warnings.slice(before).join('\n') assert.ok(said.includes('actuate'), '무엇이 빠졌는지 말한다') assert.ok(said.includes(a.type), '어느 커넥터인지 말한다') }) test('여럿이 어긋나면 다 말한다 — 하나만 말하면 고친 뒤 또 막힌다', () => { const before = warnings.length const a = adapter({ openLiveFeed: () => () => {}, control: async () => ({ ok: true }), actuate: async () => ({ ok: true }) }) registerAdapterType(a) const said = warnings.slice(before).join('\n') for (const name of ['live', 'control', 'actuate']) { assert.ok(said.includes(name), `${name} 를 말하지 않았다`) } }) test('★ 능력 이름이 늘면 검사도 함께 는다 — 표 하나에서 돈다', () => { /* * 예전에는 검사할 능력 셋이 코드 안에 손으로 적혀 있었다(`['live','control']`). 새 능력을 넣을 때 * 판정에는 넣고 검사에는 잊으면, **구현했는데 선언 안 한 것을 아무도 말해 주지 않는다** — 오늘 그 * 상태였다. * * 지금은 능력↔메서드 표 하나에서 돈다. 이 시험은 그 표가 실제로 셋을 다 덮는지 본다 — 넷째 능력을 * 넣고 표에 안 적으면 여기서 걸린다. */ const cases: [string, Record][] = [ ['live', { openLiveFeed: () => () => {} }], ['control', { control: async () => ({ ok: true }) }], ['actuate', { actuate: async () => ({ ok: true }) }] ] for (const [name, impl] of cases) { const before = warnings.length registerAdapterType(adapter(impl)) assert.ok( warnings.slice(before).join('\n').includes(name), `${name} 를 구현하고 선언하지 않았는데 아무 말도 하지 않았다 — 검사 표에 그 이름이 없다` ) } }) test('선언과 구현이 맞으면 아무 말도 하지 않는다 — 소음이 되면 아무도 읽지 않는다', () => { const before = warnings.length registerAdapterType(adapter({ openLiveFeed: () => () => {}, capabilities: ['live'] })) registerAdapterType(adapter({})) assert.equal(warnings.length, before) }) test('★ 선언했는데 증명이 없는 쪽도 말해 준다 — 걸러 내고 조용하면 아무도 못 듣는다', () => { /* * 2026-09-16 에 뒤집었다(ADR-0036 ③ 개정). 이 시험은 그전까지 「여기서 두 번 말하지 않는다」였다 — * 판정이 걸러 내니 경고는 반대 방향만 보면 된다는 읽기였다. * * 그런데 걸러 낸 사실을 **아무도 듣지 못한다.** 커넥터를 만든 사람은 선언했으니 된 줄 알고, 화면은 * 못 한다고 말하고, 그 사이에 오류가 하나도 없다. 한 방향만 보는 검사는 **없는 것을 있다고** * (선언만 믿을 때) 또는 **있는 것을 없다고**(증명 형태가 하나뿐일 때) 말하게 된다. 둘 다 조용하다. */ const before = warnings.length const a = adapter({ capabilities: ['live'] }) registerAdapterType(a) const said = warnings.slice(before).join('\n') assert.ok(said.includes(a.type), '어느 커넥터인지 말한다') assert.ok(said.includes('live'), '무엇이 증명되지 않았는지 말한다') assert.ok(said.includes('openLiveFeed') && said.includes('handleInbound'), '무엇을 만들면 되는지 둘 다 말한다') assert.deepEqual(capabilitiesOf(a), [], '판정은 그대로 걸러 낸다 — 경고는 그 사실을 들리게 할 뿐이다') }) test('★ 밀어 넣는 원본도 라이브다 — handleInbound 가 live 를 증명한다', () => { /* * `operato-plant` 은 outbox 가 웹훅으로 밀어 넣는 쪽이라 `openLiveFeed` 가 없다. 증명을 그 하나로 * 두었을 때 **실 생산 사실을 나르는 유일한 커넥터가 판정에서 live 가 아니었다.** * * 당김이냐 밀림이냐는 상대 시스템의 성질이지 우리 능력의 종류가 아니다. */ const pushed = adapter({ capabilities: ['live'], handleInbound: () => ({ records: [] }) }) const pulled = adapter({ capabilities: ['live'], openLiveFeed: () => () => {} }) assert.deepEqual(capabilitiesOf(pushed), ['live'], '밀어 넣는 쪽이 live 로 서지 않는다') assert.deepEqual(capabilitiesOf(pulled), ['live'], '당겨 오는 쪽도 그대로') }) test('★ handleInbound 만 있고 선언을 잊어도 말해 준다 — 증명이 둘이면 경고도 둘을 봐야 한다', () => { const before = warnings.length const a = adapter({ handleInbound: () => ({ records: [] }) }) registerAdapterType(a) const said = warnings.slice(before).join('\n') assert.ok(said.includes('live'), 'handleInbound 를 live 의 증명으로 세지 않는다') assert.ok(said.includes(a.type)) }) test('★ 등록을 막지 않는다 — 커넥터 하나 때문에 호스트가 못 뜨면 안 된다', () => { const a = adapter({ openLiveFeed: () => () => {} }) assert.doesNotThrow(() => registerAdapterType(a)) /* 등록은 되었고, 다만 그 능력은 선언되지 않았으므로 판정에서 빠진다 — 그 사실이 경고와 짝이다. */ assert.deepEqual(capabilitiesOf(a), []) }) test('★ 어댑터 목록이 내보내는 능력은 판정값이다 — 선언 원본이 아니다', async () => { /* * 이것이 없어서 `capabilitiesOf` 의 판정값을 **읽는 자리가 하나도 없었다.** `listAdapters()` 가 * `a.capabilities` 를 그대로 냈고, 연결 화면은 그것을 읽었다. 선언을 믿지 않으려고 만든 문을 * 아무도 지나지 않은 셈이다 — 구현 없이 능력을 적기만 하면 화면에서 가능한 것으로 보였다. */ const { listAdapters } = await import('../server/service/reference/reference-adapter.js') const lying = adapter({ capabilities: ['live', 'actuate'] }) const honest = adapter({ capabilities: ['live'], handleInbound: () => ({ records: [] }) }) registerAdapterType(lying) registerAdapterType(honest) const listed = listAdapters() const byType = new Map(listed.map(one => [one.type, one.capabilities ?? []])) assert.ok(listed.length > 0, '목록이 비어 있으면 이 시험이 아무것도 안 잰다') assert.deepEqual(byType.get(lying.type), [], '증명 없는 선언이 목록에 그대로 나간다') assert.deepEqual(byType.get(honest.type), ['live'], '증명된 능력은 그대로 나간다') })