/* * 실행기 — 모은 사실을 프로브 결과로 옮기고, 단계 판정과 프로파일까지 한 번에 낸다. * * I/O 는 여기 없다(호출자가 모은다). 그래서 판정 전체가 테스트된다. */ import { PROBE_SEQUENCE, foldProbeResults, notApplicable, plannedProbes, type ProbeId, type ProbeResult } from './integration-probes.ts' import { assessStagePath, forbiddenClaims, type StagePath } from './stage-path.ts' import { narrowProfile, unknownProfile, type FeedKind, type TargetProfile } from './integration-target-profile.ts' /** 호출자가 모아 오는 사실. 전부 `null` 이 「모른다」다 — 0 이나 false 로 채우지 않는다. */ export interface SuiteFacts { /** 이 트윈이 무엇인가. 시뮬은 원천에서 받는 것이 없다(라이브 프로브는 해당 없음). */ subject: 'live' | 'sim' | 'stopped' | 'unknown' /** 레퍼런스(원천 어댑터)에서 왔나. 템플릿으로 세운 트윈은 옮겨 온 마스터가 없다. */ fromReference: boolean /** `capabilitiesOf(adapter)` — 선언∩구현. */ adapterCapabilities: readonly string[] connectOk: boolean | null siteCount: number | null locationCount: number | null itemCount: number | null /** 마지막 동기의 못 옮긴 값 수(`TwinReference.lastWarningCount`). */ lastWarningCount: number | null /** 저널 누적 — 재시작에도 남는다(인메모리 계기와 다르다). */ journalTotal: number | null /** 저널의 `action='DELETE'` 수 — **반출 관측**. 승격 전 행은 세어지지 않으므로 `null` 이 정직하다. */ removalsJournaled: number | null specCoverage: { declared: number; total: number } | null forecastRan: boolean | null feed: FeedKind } export interface SuiteAssessment { results: ProbeResult[] profile: TargetProfile path: StagePath notApplicable: { id: ProbeId; detail?: string }[] forbiddenClaims: string[] /** 다음에 돌릴 프로브 — 쓰기 동의 없이는 쓰기 프로브가 빠진다. */ planned: ProbeId[] } const r = (id: ProbeId, o: Partial): ProbeResult => ({ id, ran: false, ...o }) /** 값이 있으면 돌린 것으로 보고 판정한다. `null` = 안 돌렸다(실패 아님). */ const fromNumber = (id: ProbeId, v: number | null, min = 1): ProbeResult => v === null ? r(id, { ran: false }) : r(id, { ran: true, passed: v >= min, count: v }) export function assessSuite(f: SuiteFacts, opts: { allowWrites?: boolean } = {}): SuiteAssessment { const liveApplies = f.subject !== 'sim' const results: ProbeResult[] = [ f.connectOk === null ? r('connect', { ran: false }) : r('connect', { ran: true, passed: f.connectOk }), fromNumber('discover', f.siteCount), fromNumber('master', f.locationCount), /* 레퍼런스 없이 세운 트윈에는 옮겨 온 마스터가 없다 — 「못 옮긴 값」을 물을 대상이 아니다. */ f.fromReference ? fromNumber('master-warnings', f.lastWarningCount, 0) : r('master-warnings', { ran: false, applicable: false, detail: 'not-from-reference' }), fromNumber('items', f.itemCount), liveApplies ? fromNumber('live-ingest', f.journalTotal) : r('live-ingest', { ran: false, applicable: false, detail: 'subject-is-simulation' }), liveApplies ? fromNumber('live-removal', f.removalsJournaled, 1) : r('live-removal', { ran: false, applicable: false, detail: 'subject-is-simulation' }), f.specCoverage === null ? r('declarations', { ran: false }) : r('declarations', { ran: true, passed: f.specCoverage.declared > 0, coverage: f.specCoverage }), f.forecastRan === null ? r('forecast', { ran: false }) : r('forecast', { ran: true, passed: f.forecastRan }), r('actuate-declared', { ran: true, passed: f.adapterCapabilities.includes('actuate') }), /* 액추에이션은 계약에 아직 없다(ADR-0036). 측정을 만들면 없는 것을 있는 척한다. */ r('actuate-effect', { ran: false, detail: 'actuation-not-wired' }) ] /* 반출을 셌는데 0 이면 「세지 않음」과 구별해 프로브를 실패로 남긴다 — count 0 은 사실이다. */ const removal = results.find(x => x.id === 'live-removal')! if (removal.ran && f.removalsJournaled === 0) removal.count = 0 const profile = narrowProfile(unknownProfile(), { feed: f.feed, multiSite: f.siteCount === null ? null : f.siteCount > 1, write: f.adapterCapabilities.includes('actuate') ? 'command' : 'unknown' }) const evidence = { ...foldProbeResults(results), subject: liveApplies ? ('live' as const) : ('sim' as const), feed: f.feed } const path = assessStagePath(evidence) return { results, profile, path, notApplicable: notApplicable(results), forbiddenClaims: forbiddenClaims(path), planned: plannedProbes(results, opts) } } /** 시퀀스 명세 — 화면이 회색 칸의 이유(해당 없음·미실행)를 나란히 보이도록 함께 낸다. */ export const SUITE_SPEC = PROBE_SEQUENCE