@things-factory/headless-twin 10.0.7 → 10.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist-server/engine/attention-digest.d.ts +52 -0
  2. package/dist-server/engine/attention-digest.js +76 -0
  3. package/dist-server/engine/attention-digest.js.map +1 -0
  4. package/dist-server/engine/board-vocabulary.d.ts +17 -0
  5. package/dist-server/engine/board-vocabulary.js +63 -0
  6. package/dist-server/engine/board-vocabulary.js.map +1 -0
  7. package/dist-server/engine/command-routing.d.ts +33 -0
  8. package/dist-server/engine/command-routing.js +53 -0
  9. package/dist-server/engine/command-routing.js.map +1 -0
  10. package/dist-server/engine/entity-delta.d.ts +13 -6
  11. package/dist-server/engine/entity-delta.js +38 -12
  12. package/dist-server/engine/entity-delta.js.map +1 -1
  13. package/dist-server/engine/index.d.ts +6 -0
  14. package/dist-server/engine/index.js +10 -0
  15. package/dist-server/engine/index.js.map +1 -1
  16. package/dist-server/engine/kpi-baseline.d.ts +78 -0
  17. package/dist-server/engine/kpi-baseline.js +123 -0
  18. package/dist-server/engine/kpi-baseline.js.map +1 -0
  19. package/dist-server/engine/kpi-broadcast.d.ts +4 -0
  20. package/dist-server/engine/kpi-broadcast.js +16 -0
  21. package/dist-server/engine/kpi-broadcast.js.map +1 -0
  22. package/dist-server/engine/kpi-query.d.ts +31 -0
  23. package/dist-server/engine/kpi-query.js +50 -2
  24. package/dist-server/engine/kpi-query.js.map +1 -1
  25. package/dist-server/engine/live-attentions.d.ts +1 -0
  26. package/dist-server/engine/live-attentions.js +7 -1
  27. package/dist-server/engine/live-attentions.js.map +1 -1
  28. package/dist-server/engine/runtime-key.d.ts +15 -0
  29. package/dist-server/engine/runtime-key.js +64 -0
  30. package/dist-server/engine/runtime-key.js.map +1 -0
  31. package/dist-server/engine/state-axes.d.ts +16 -0
  32. package/dist-server/engine/state-axes.js +54 -0
  33. package/dist-server/engine/state-axes.js.map +1 -0
  34. package/dist-server/engine/twin-engine.d.ts +92 -16
  35. package/dist-server/engine/twin-engine.js +335 -77
  36. package/dist-server/engine/twin-engine.js.map +1 -1
  37. package/dist-server/engine/twin-level.d.ts +23 -0
  38. package/dist-server/engine/twin-level.js +52 -0
  39. package/dist-server/engine/twin-level.js.map +1 -0
  40. package/dist-server/engine/warm-start.d.ts +58 -12
  41. package/dist-server/engine/warm-start.js +80 -9
  42. package/dist-server/engine/warm-start.js.map +1 -1
  43. package/dist-server/service/reference/discovery-result.d.ts +34 -0
  44. package/dist-server/service/reference/discovery-result.js +84 -0
  45. package/dist-server/service/reference/discovery-result.js.map +1 -0
  46. package/dist-server/service/reference/ingest-space.d.ts +30 -0
  47. package/dist-server/service/reference/ingest-space.js +63 -0
  48. package/dist-server/service/reference/ingest-space.js.map +1 -0
  49. package/dist-server/service/reference/knob-defaults.d.ts +20 -0
  50. package/dist-server/service/reference/knob-defaults.js +59 -0
  51. package/dist-server/service/reference/knob-defaults.js.map +1 -0
  52. package/dist-server/service/reference/reference-live.js +2 -2
  53. package/dist-server/service/reference/reference-live.js.map +1 -1
  54. package/dist-server/service/reference/reference-master.d.ts +37 -2
  55. package/dist-server/service/reference/reference-master.js +55 -5
  56. package/dist-server/service/reference/reference-master.js.map +1 -1
  57. package/dist-server/service/reference/reference-resolver.d.ts +2 -2
  58. package/dist-server/service/reference/reference-resolver.js +71 -15
  59. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  60. package/dist-server/service/twin-attention/twin-attention-query.d.ts +8 -1
  61. package/dist-server/service/twin-attention/twin-attention-query.js +39 -8
  62. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  63. package/dist-server/service/twin-control/twin-control-mutation.d.ts +2 -0
  64. package/dist-server/service/twin-control/twin-control-mutation.js +29 -10
  65. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  66. package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
  67. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +4 -2
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-journal/twin-journal-query.d.ts +6 -2
  71. package/dist-server/service/twin-journal/twin-journal-query.js +25 -7
  72. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  73. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +8 -0
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +23 -1
  75. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  76. package/dist-server/service/twin-metrics/twin-metrics-query.js +1 -1
  77. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  78. package/dist-server/service/twin-space/twin-space-resolver.js +9 -0
  79. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  80. package/dist-server/service/twin-state/twin-state-subscription.js +1 -1
  81. package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
  82. package/dist-server/service/twin-structure/twin-structure.js +2 -1
  83. package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
  84. package/dist-server/service/twin-target/twin-target-resolver.js +21 -4
  85. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
  86. package/dist-server/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +6 -6
  88. package/server/engine/attention-digest.ts +102 -0
  89. package/server/engine/board-vocabulary.ts +61 -0
  90. package/server/engine/command-routing.ts +67 -0
  91. package/server/engine/entity-delta.ts +33 -11
  92. package/server/engine/index.ts +10 -0
  93. package/server/engine/kpi-baseline.ts +202 -0
  94. package/server/engine/kpi-broadcast.ts +13 -0
  95. package/server/engine/kpi-query.ts +81 -3
  96. package/server/engine/live-attentions.ts +7 -2
  97. package/server/engine/runtime-key.ts +58 -0
  98. package/server/engine/state-axes.ts +55 -0
  99. package/server/engine/twin-engine.ts +350 -77
  100. package/server/engine/twin-level.ts +48 -0
  101. package/server/engine/warm-start.ts +130 -16
  102. package/server/service/reference/discovery-result.ts +95 -0
  103. package/server/service/reference/ingest-space.ts +70 -0
  104. package/server/service/reference/knob-defaults.ts +59 -0
  105. package/server/service/reference/reference-live.ts +2 -2
  106. package/server/service/reference/reference-master.ts +94 -8
  107. package/server/service/reference/reference-resolver.ts +80 -16
  108. package/server/service/twin-attention/twin-attention-query.ts +43 -6
  109. package/server/service/twin-control/twin-control-mutation.ts +31 -12
  110. package/server/service/twin-forecast/twin-forecast-query.ts +3 -2
  111. package/server/service/twin-instance/twin-instance.ts +6 -2
  112. package/server/service/twin-journal/twin-journal-query.ts +35 -5
  113. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +18 -2
  114. package/server/service/twin-metrics/twin-metrics-query.ts +1 -1
  115. package/server/service/twin-space/twin-space-resolver.ts +10 -1
  116. package/server/service/twin-state/twin-state-subscription.ts +1 -1
  117. package/server/service/twin-structure/twin-structure.ts +4 -1
  118. package/server/service/twin-target/twin-target-resolver.ts +22 -4
  119. package/test/attention-digest.test.ts +135 -0
  120. package/test/board-vocabulary.test.ts +114 -0
  121. package/test/capability-mapping.test.ts +4 -4
  122. package/test/command-routing.test.ts +61 -0
  123. package/test/discovery-result.test.ts +75 -0
  124. package/test/entity-delta.test.ts +25 -25
  125. package/test/ingest-bench.test.ts +3 -3
  126. package/test/ingest-space.test.ts +50 -0
  127. package/test/knob-defaults.test.ts +72 -0
  128. package/test/kpi-baseline-db.test.ts +214 -0
  129. package/test/kpi-baseline.test.ts +196 -0
  130. package/test/kpi-query-bench.test.ts +128 -0
  131. package/test/live-mirror-parity.test.ts +35 -2
  132. package/test/master-to-twin.test.ts +7 -3
  133. package/test/mutation-gate.test.ts +108 -0
  134. package/test/oee-accumulator.test.ts +65 -1
  135. package/test/registry-key-guard.test.ts +80 -0
  136. package/test/runtime-key.test.ts +66 -0
  137. package/test/scale-twin-bench.test.ts +2 -2
  138. package/test/state-axes.test.ts +74 -0
  139. package/test/streamline-e2e.test.ts +2 -2
  140. package/test/structure-revision-db.test.ts +5 -4
  141. package/test/tenant-registry-db.test.ts +149 -0
  142. package/test/warm-start-seam.test.ts +140 -0
  143. package/test/warm-start.test.ts +138 -4
@@ -0,0 +1,196 @@
1
+ /*
2
+ * 여러 구간 동시 비교 — **"어제보다 나아졌다" 를 넘어선다.**
3
+ *
4
+ * 비교 축이 한 구간만 볼 때는 판단이 한 문장에서 멈췄다: "어제보다 나아졌다." 어제도 나빴으면 쓸모가
5
+ * 없다. 최근 며칠의 같은 시간대를 함께 보면 목표 없이도 "지난 7일 중 가장 낮다" 를 말할 수 있다.
6
+ *
7
+ * 이 테스트가 지키는 것은 산수보다 **정직성**이다. 이 도메인에서 값이 없는 것을 0 으로 읽으면 시간
8
+ * 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 최고 성적으로** 보고된다 — 목표 판정에서 이미 겪은
9
+ * 결함이라 여기서 되풀이하지 않는다.
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+
14
+ import {
15
+ MAX_BASELINE_PERIODS,
16
+ baselineWindows,
17
+ summarizeBaseline,
18
+ type MetricSpecLike
19
+ } from '../server/engine/kpi-baseline.ts'
20
+
21
+ const MS_DAY = 24 * 60 * 60_000
22
+ const HOUR = 60 * 60_000
23
+
24
+ /* 실제 표(`TWIN_METRIC`)의 성질만 흉내 낸 최소 사양 — 어휘는 그쪽이 소유한다. */
25
+ const SPECS = {
26
+ 'throughput.tasks': { direction: 'higher', unit: 'count' },
27
+ 'workTime.p50Ms': { direction: 'lower', unit: 'ms' }
28
+ } satisfies Record<string, MetricSpecLike>
29
+
30
+ type M = keyof typeof SPECS
31
+
32
+ /** 과거 창 표본 — 값만 신경 쓰는 테스트를 짧게 쓰기 위한 도우미. */
33
+ const sample = (dayBack: number, values: Partial<Record<M, number | undefined>>) => ({
34
+ fromMs: -dayBack * MS_DAY,
35
+ toMs: -dayBack * MS_DAY + HOUR,
36
+ values
37
+ })
38
+
39
+ test('같은 시간대를 하루씩 뒤로 옮긴다 — 가까운 것부터', () => {
40
+ const from = 10 * MS_DAY
41
+ const to = from + HOUR
42
+
43
+ const windows = baselineWindows(from, to, 3)
44
+
45
+ assert.equal(windows.length, 3)
46
+ assert.deepEqual(windows[0], { fromMs: from - MS_DAY, toMs: to - MS_DAY }, '어제가 먼저')
47
+ assert.deepEqual(windows[2], { fromMs: from - 3 * MS_DAY, toMs: to - 3 * MS_DAY })
48
+ /* 길이는 그대로여야 한다 — 길이가 달라지면 처리량을 비교할 수 없다. */
49
+ for (const w of windows) assert.equal(w.toMs - w.fromMs, to - from)
50
+ })
51
+
52
+ test('주 단위는 같은 요일을 본다 — 주말·평일이 섞이지 않게', () => {
53
+ const from = 30 * MS_DAY
54
+ const windows = baselineWindows(from, from + HOUR, 2, 'week')
55
+
56
+ assert.equal(windows[0].fromMs, from - 7 * MS_DAY)
57
+ assert.equal(windows[1].fromMs, from - 14 * MS_DAY)
58
+ })
59
+
60
+ test('구간 수에 상한이 있고, 이상한 입력은 창을 만들지 않는다', () => {
61
+ assert.equal(baselineWindows(0, HOUR, 999).length, MAX_BASELINE_PERIODS)
62
+ assert.equal(baselineWindows(0, HOUR, 0).length, 0)
63
+ assert.equal(baselineWindows(0, HOUR, -3).length, 0)
64
+ /* 끝이 시작보다 앞이면 창이 아니다 — 지어내지 않는다. */
65
+ assert.equal(baselineWindows(HOUR, 0, 3).length, 0)
66
+ assert.equal(baselineWindows(Number.NaN, HOUR, 3).length, 0)
67
+ })
68
+
69
+ test('분포는 측정된 표본만으로 만들되, **결측 창도 남긴다**', () => {
70
+ /* 빼고 말하면 "지난 5일" 이 실제로는 3일이었다는 것을 아무도 모른다. */
71
+ const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 50 }, [
72
+ sample(1, { 'throughput.tasks': 40 }),
73
+ sample(2, {}), // 기록 없음
74
+ sample(3, { 'throughput.tasks': 60 }),
75
+ sample(4, { 'throughput.tasks': undefined }), // 기록 없음
76
+ sample(5, { 'throughput.tasks': 80 })
77
+ ])
78
+
79
+ assert.equal(row.requested, 5)
80
+ assert.equal(row.measuredCount, 3)
81
+ assert.equal(row.samples.length, 5, '요청한 창 전부를 돌려준다')
82
+ assert.deepEqual(
83
+ row.samples.map(s => s.measured),
84
+ [true, false, true, false, true]
85
+ )
86
+ assert.deepEqual([row.min, row.median, row.max], [40, 60, 80])
87
+ })
88
+
89
+ test('현재 값이 없으면 자리를 말하지 않는다 — 결측을 0 으로 읽으면 최고 성적으로 뒤집힌다', () => {
90
+ /* 시간 지표는 "낮을수록 좋다" 라서 결측을 0 으로 놓으면 **일이 없던 구간이 1등**이 된다. */
91
+ const [row] = summarizeBaseline({ 'workTime.p50Ms': SPECS['workTime.p50Ms'] }, {}, [
92
+ sample(1, { 'workTime.p50Ms': 30_000 }),
93
+ sample(2, { 'workTime.p50Ms': 40_000 })
94
+ ])
95
+
96
+ assert.equal(row.current, undefined)
97
+ assert.equal(row.standing, undefined)
98
+ assert.equal(row.rank, undefined)
99
+ assert.equal(row.noPosition, 'no-current')
100
+ /* 그래도 분포 자체는 낸다 — "평소 얼마였나" 는 여전히 답할 수 있는 사실이다. */
101
+ assert.deepEqual([row.min, row.max], [30_000, 40_000])
102
+ })
103
+
104
+ test('표본이 둘 미만이면 자리를 말하지 않는다 — 하나를 분포라 부를 수 없다', () => {
105
+ const one = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10 }, [
106
+ sample(1, { 'throughput.tasks': 40 })
107
+ ])[0]
108
+
109
+ assert.equal(one.noPosition, 'too-few-samples')
110
+ assert.equal(one.standing, undefined)
111
+
112
+ const none = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10 }, [])[0]
113
+ assert.equal(none.noPosition, 'too-few-samples')
114
+ assert.equal(none.min, undefined)
115
+ })
116
+
117
+ test('방향을 지표가 안다 — 같은 자리가 처리량과 소요시간에서 반대로 읽힌다', () => {
118
+ const past = [sample(1, { 'throughput.tasks': 40, 'workTime.p50Ms': 40_000 }), sample(2, { 'throughput.tasks': 60, 'workTime.p50Ms': 60_000 }), sample(3, { 'throughput.tasks': 80, 'workTime.p50Ms': 80_000 })]
119
+
120
+ /* 값이 표본 전부보다 크다 — 처리량은 최고, 소요시간은 최악이다. */
121
+ const rows = summarizeBaseline(SPECS, { 'throughput.tasks': 90, 'workTime.p50Ms': 90_000 }, past)
122
+ const by = Object.fromEntries(rows.map(r => [r.metric, r]))
123
+
124
+ assert.equal(by['throughput.tasks'].standing, 'best')
125
+ assert.equal(by['workTime.p50Ms'].standing, 'worst')
126
+ /* 순위는 **방향 없는 사실**이다 — 둘 다 오름차순 4번째다. */
127
+ assert.equal(by['throughput.tasks'].rank, 4)
128
+ assert.equal(by['workTime.p50Ms'].rank, 4)
129
+ assert.equal(by['workTime.p50Ms'].rankOf, 4)
130
+ })
131
+
132
+ test('전부보다 나쁘면 worst — 처리량이 지난 3일 중 가장 낮은 경우', () => {
133
+ const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10 }, [
134
+ sample(1, { 'throughput.tasks': 40 }),
135
+ sample(2, { 'throughput.tasks': 60 }),
136
+ sample(3, { 'throughput.tasks': 80 })
137
+ ])
138
+
139
+ assert.equal(row.standing, 'worst')
140
+ assert.equal(row.rank, 1, '오름차순 첫째')
141
+ })
142
+
143
+ test('중앙값과 같으면 typical — 임의의 임계를 만들지 않는다', () => {
144
+ const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 60 }, [
145
+ sample(1, { 'throughput.tasks': 40 }),
146
+ sample(2, { 'throughput.tasks': 60 }),
147
+ sample(3, { 'throughput.tasks': 80 })
148
+ ])
149
+
150
+ assert.equal(row.standing, 'typical')
151
+ })
152
+
153
+ test('중앙값 양쪽을 방향대로 가른다', () => {
154
+ const past = [sample(1, { 'throughput.tasks': 40 }), sample(2, { 'throughput.tasks': 60 }), sample(3, { 'throughput.tasks': 80 })]
155
+ const spec = { 'throughput.tasks': SPECS['throughput.tasks'] }
156
+
157
+ assert.equal(summarizeBaseline(spec, { 'throughput.tasks': 70 }, past)[0].standing, 'better-than-typical')
158
+ assert.equal(summarizeBaseline(spec, { 'throughput.tasks': 50 }, past)[0].standing, 'worse-than-typical')
159
+
160
+ /* 소요시간은 반대다 — 중앙값보다 낮은 것이 좋다. */
161
+ const lower = { 'workTime.p50Ms': SPECS['workTime.p50Ms'] }
162
+ const pastMs = [sample(1, { 'workTime.p50Ms': 40 }), sample(2, { 'workTime.p50Ms': 60 }), sample(3, { 'workTime.p50Ms': 80 })]
163
+ assert.equal(summarizeBaseline(lower, { 'workTime.p50Ms': 50 }, pastMs)[0].standing, 'better-than-typical')
164
+ assert.equal(summarizeBaseline(lower, { 'workTime.p50Ms': 70 }, pastMs)[0].standing, 'worse-than-typical')
165
+ })
166
+
167
+ test('짝수 표본의 중앙값은 가운데 둘의 평균이다', () => {
168
+ const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 55 }, [
169
+ sample(1, { 'throughput.tasks': 40 }),
170
+ sample(2, { 'throughput.tasks': 50 }),
171
+ sample(3, { 'throughput.tasks': 60 }),
172
+ sample(4, { 'throughput.tasks': 70 })
173
+ ])
174
+
175
+ assert.equal(row.median, 55)
176
+ assert.equal(row.standing, 'typical')
177
+ })
178
+
179
+ test('열거된 지표만 낸다 — 모르는 이름을 짐작하지 않는다', () => {
180
+ const rows = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10, 'made.up': 99 } as any, [
181
+ sample(1, { 'throughput.tasks': 5 } as any)
182
+ ])
183
+
184
+ assert.deepEqual(rows.map(r => r.metric), ['throughput.tasks'])
185
+ })
186
+
187
+ test('무한·NaN 은 측정으로 세지 않는다', () => {
188
+ const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': Number.NaN }, [
189
+ sample(1, { 'throughput.tasks': Number.POSITIVE_INFINITY }),
190
+ sample(2, { 'throughput.tasks': 40 }),
191
+ sample(3, { 'throughput.tasks': 60 })
192
+ ])
193
+
194
+ assert.equal(row.measuredCount, 2, '무한은 표본이 아니다')
195
+ assert.equal(row.noPosition, 'no-current', 'NaN 은 값이 아니다')
196
+ })
@@ -0,0 +1,128 @@
1
+ /*
2
+ * 구간 성과 질의 비용 측정 — **방송이냐 질의냐를 숫자로 정한다.**
3
+ *
4
+ * ── 왜 재는가 (2026-08-06) ─────────────────────────────────────────────────
5
+ * 성과 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려 한다. 두 방법이 있다.
6
+ *
7
+ * · 방송 — 서버가 주기마다 전 단계를 계산해 밀어 준다. 자리가 트윈당 최대 200개고 지표가 6개라
8
+ * 태그가 1,200개가 된다. **보드에 카드를 하나도 안 놓아도 계산은 돈다.**
9
+ * · 질의 — 카드가 필요할 때 묻는다. **보드에 놓인 카드 수만큼만** 든다. 시각(`toTime`)도 물을 수 있어
10
+ * 타임라인과 맞고, 공간 단위 합산(여러 트윈을 한 번에 접기)은 질의에만 있다.
11
+ *
12
+ * 질의로 정하려면 **한 번의 비용**을 알아야 한다. 카드 20장이 30초마다 물으면 감당되는지가 결정을
13
+ * 가른다. 짐작하지 않고 잰다.
14
+ *
15
+ * 빌드 산출물을 실행한다 — **소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌린다.**
16
+ */
17
+ import { test, before, after } from 'node:test'
18
+ import { createRequire } from 'node:module'
19
+ import type { DataSource } from 'typeorm'
20
+
21
+ const req = createRequire(import.meta.url)
22
+ const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
23
+ const { entities: authEntities } = req('@things-factory/auth-base')
24
+ const { entities: twinEntities } = req('../dist-server/service/index.js')
25
+ const { computeTwinKpi } = req('../dist-server/engine/index.js')
26
+ const { TwinEvent } = req('../dist-server/service/twin-event/twin-event.js')
27
+ const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js')
28
+ const { DataSource: TypeOrmDataSource } = req('typeorm')
29
+
30
+ let ds: DataSource
31
+ let domainId: string
32
+
33
+ const INSTANCE = 'kpi-bench'
34
+ const TO = Date.parse('2026-03-10T10:00:00.000Z')
35
+ const WINDOW_MIN = 60
36
+
37
+ /** 자리 200개 — 실측한 최대 규모(트윈당 자리 최대 200, 평균 19). */
38
+ const LOCATIONS = 200
39
+ /** 창 안의 완료 작업 수 — 한 건이 두 줄(시작·완료)이라 이벤트는 두 배가 된다. */
40
+ const TASK_COUNTS = [500, 2_000, 10_000]
41
+
42
+ function taskRows(count: number, revBase: number) {
43
+ const rows: any[] = []
44
+ for (let i = 0; i < count; i++) {
45
+ const done = TO - 60_000 - (i % (WINDOW_MIN * 60)) * 1000
46
+ const started = done - 30_000
47
+ const at = `loc-${i % LOCATIONS}`
48
+ rows.push(
49
+ {
50
+ domain: { id: domainId },
51
+ instanceId: INSTANCE,
52
+ revision: revBase + i * 2,
53
+ eventType: 'task.status',
54
+ eventTime: new Date(started).toISOString(),
55
+ payload: { data: { taskId: `t-${i}`, status: 'in-progress', resourceRef: 'fk-1', locationRef: at } }
56
+ },
57
+ {
58
+ domain: { id: domainId },
59
+ instanceId: INSTANCE,
60
+ revision: revBase + i * 2 + 1,
61
+ eventType: 'task.status',
62
+ eventTime: new Date(done).toISOString(),
63
+ payload: { data: { taskId: `t-${i}`, status: 'completed', resourceRef: 'fk-1', locationRef: at } }
64
+ }
65
+ )
66
+ }
67
+ return rows
68
+ }
69
+
70
+ before(async () => {
71
+ ds = new TypeOrmDataSource({
72
+ type: 'sqlite',
73
+ database: ':memory:',
74
+ synchronize: true,
75
+ dropSchema: true,
76
+ namingStrategy: new NamingStrategy(),
77
+ entities: [...shellEntities, ...authEntities, ...twinEntities],
78
+ logging: false
79
+ })
80
+ await ds.initialize()
81
+ addDataSource('default', ds)
82
+ const d = await ds.getRepository(Domain).save({ name: 'kpi-bench', subdomain: 'kpi-bench' } as any)
83
+ domainId = d.id
84
+
85
+ /* 자리 계층 — 자리 200개를 구역 4개에 나눠 붙인다(구역 단위 접기 비용도 재려면 부모가 있어야 한다). */
86
+ const locations = Array.from({ length: LOCATIONS }, (_, i) => ({
87
+ id: `loc-${i}`,
88
+ type: 'storage',
89
+ capacity: 10,
90
+ parentId: `area-${i % 4}`
91
+ }))
92
+ await ds.getRepository(TwinInstance).save({
93
+ domain: { id: domainId },
94
+ instanceId: INSTANCE,
95
+ kind: 'wms',
96
+ status: 'stopped',
97
+ board: { locations, equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'loc-0' }] }
98
+ } as any)
99
+ })
100
+
101
+ after(async () => {
102
+ await ds?.destroy()
103
+ })
104
+
105
+ const ms = async (fn: () => Promise<any>) => {
106
+ const t = process.hrtime.bigint()
107
+ await fn()
108
+ return Number(process.hrtime.bigint() - t) / 1e6
109
+ }
110
+
111
+ test('bench: 구간 성과 폴드 한 번의 비용 vs 창 안의 작업 수', async () => {
112
+ let seeded = 0
113
+ for (const count of TASK_COUNTS) {
114
+ await ds.getRepository(TwinEvent).save(taskRows(count - seeded, seeded * 2 + 1000))
115
+ seeded = count
116
+
117
+ const args = { domainId, instanceId: INSTANCE, toTime: new Date(TO).toISOString(), windowMinutes: WINDOW_MIN }
118
+ await computeTwinKpi(args) // 워밍업(연결·스키마 캐시)
119
+
120
+ const plain = await ms(() => computeTwinKpi(args))
121
+ const byArea = await ms(() => computeTwinKpi({ ...args, groupBy: 'area' }))
122
+
123
+ console.log(` 작업 ${count.toLocaleString()}건(이벤트 ${(count * 2).toLocaleString()}줄) · 자리 ${LOCATIONS}개`)
124
+ console.log(` 폴드 1회 ${plain.toFixed(1)}ms`)
125
+ console.log(` 구역별 폴드 1회 ${byArea.toFixed(1)}ms`)
126
+ console.log(` 카드 20장 동시 ${(plain * 20).toFixed(0)}ms (직렬 상한 — 실제로는 한 번 접어 나눠 쓸 수 있다)`)
127
+ }
128
+ })
@@ -77,8 +77,8 @@ function bothPayloads() {
77
77
  const proj = new StateProjector(board)
78
78
  for (const env of events) proj.apply(env)
79
79
  return {
80
- sim: sortByTag(buildEntityDeltas(kernel.getSnapshot())),
81
- live: sortByTag(buildEntityDeltas(proj.snapshot()))
80
+ sim: sortByTag(buildEntityDeltas(kernel.getSnapshot(), 't1')),
81
+ live: sortByTag(buildEntityDeltas(proj.snapshot(), 't1'))
82
82
  }
83
83
  }
84
84
 
@@ -231,3 +231,36 @@ test('★ withLiveAttentions 는 채널을 하나도 떨어뜨리지 않는다(
231
231
  for (const expected of ['breakdown', 'bottleneck', 'hold', 'work-backlog'])
232
232
  assert.ok(kinds.has(expected), `라이브 배선이 '${expected}' 채널을 떨어뜨렸다 — 넘긴 것: ${[...kinds].join(', ') || '없음'}`)
233
233
  })
234
+
235
+ /*
236
+ * ── 되짚은 상태에도 신호가 있어야 한다 (2026-08-05) ────────────────────────
237
+ * 라이브·시뮬은 커널이 신호를 스스로 내지만, 저널을 접는 경로는 프로젝터 상태만 낸다 — 신호 키가
238
+ * 아예 없었다. 지도는 `snap.attentions` 를 읽으므로 **과거를 되짚으면 주의 레일이 텅 비었고**,
239
+ * 기동돼 있지 않은 트윈을 보는 화면도 같았다. 실 저널로 재 보니 `rosarito-wms` 는 246건이 비어 있었다.
240
+ *
241
+ * 신호는 상태에서 계산되는 것이므로 같은 공식으로 채우면 된다(두 벌을 두지 않는다). 다만 "늦었나"
242
+ * 판정이 `nowTime` 을 보므로 그 값이 먼저다 — 되짚은 시점의 정직한 "지금" 은 물어본 시각이고,
243
+ * 벽시계를 끼워 넣으면 과거 화면이 "지금 기준으로 늦었다" 고 말한다.
244
+ */
245
+ test('★ 프로젝터 폴드 상태에는 신호 키가 없다 — 보강이 필요한 이유', () => {
246
+ const { board, events } = runSim()
247
+ const proj = new StateProjector(board)
248
+ for (const env of events) proj.apply(env)
249
+ const raw: any = proj.snapshot()
250
+ assert.equal(raw.attentions, undefined, '접은 상태는 신호를 스스로 내지 않는다(커널이 아니다)')
251
+ })
252
+
253
+ test('★ 보강하면 되짚은 상태도 신호를 갖는다 — 지도의 주의 레일이 비지 않게', () => {
254
+ /* 용량 1 자리를 채워 병목이 스스로 성립하게 만든다(신호를 손으로 넣지 않는다). */
255
+ const st: any = {
256
+ revision: 3,
257
+ nowTime: '2026-08-05T00:00:00.000Z',
258
+ locations: [{ id: 'bin-tight', type: 'storage', capacity: 1, occupancy: 1 }],
259
+ equipment: [],
260
+ orders: [],
261
+ tasks: []
262
+ }
263
+ const out = withLiveAttentions(st)
264
+ assert.ok((out.attentions ?? []).length > 0, '접은 상태에서도 규칙이 신호를 낸다')
265
+ assert.equal(out.attentions![0].kind, 'bottleneck')
266
+ })
@@ -59,7 +59,11 @@ test('미지 노드 타입 경고 — 카탈로그 있으면 검증(버리진
59
59
  const m = wmsMaster()
60
60
  ;(m.locations as any).push({ id: 'weird', type: '__unknown__', role: 'location', capacity: 1 })
61
61
  const withCat = masterToTwin(m, DOMAIN_CATALOG)
62
- assert.ok(withCat.warnings.some(w => w.includes('__unknown__')), '미지 타입 경고')
62
+ /* 경고는 프로즈가 아니라 **코드+파라미터**다(화면이 다섯 언어로 렌더한다) 문구가 아니라 계약을 본다. */
63
+ assert.ok(
64
+ withCat.warnings.some(w => w.code === 'unknown-location-type' && w.params?.type === '__unknown__'),
65
+ `미지 타입 경고: ${JSON.stringify(withCat.warnings)}`
66
+ )
63
67
  assert.ok(withCat.board.locations.some((n: any) => n.id === 'weird'), '경고해도 노드는 유지(반영 우선)')
64
68
  // 정상 타입만이면 경고 0
65
69
  assert.equal(masterToTwin(wmsMaster(), DOMAIN_CATALOG).warnings.length, 0, '알려진 타입만 → 경고 0')
@@ -107,7 +111,7 @@ test('현장 물질화(#4): 표현도 geo 도 없으면 빈 표현 + 경고(정
107
111
  const { spaceContent, warnings } = masterToTwin(m)
108
112
  assert.equal(spaceContent.representations.length, 0, '합성 불가')
109
113
  assert.ok(
110
- warnings.some((w: string) => w.includes('no viewable representation')),
114
+ warnings.some(w => w.code === 'no-viewable-representation'),
111
115
  '뷰 없는 공간 경고 표면화'
112
116
  )
113
117
  })
@@ -158,7 +162,7 @@ test('내용 없는 명세는 경고한다 — 선언했는데 아무 말도 하
158
162
  m.operations = [{ key: 'cut', label: 'cutting', intent: 'process' }]
159
163
  const { warnings } = masterToTwin(m, DOMAIN_CATALOG)
160
164
  assert.ok(
161
- warnings.some(w => w.includes('cut') && w.includes('kernel defaults')),
165
+ warnings.some(w => w.code === 'operation-without-duration' && w.params?.operation === 'cut'),
162
166
  `기본값으로 굴러갈 것을 알린다: ${JSON.stringify(warnings)}`
163
167
  )
164
168
  })
@@ -0,0 +1,108 @@
1
+ /*
2
+ * 뮤테이션 게이트 가드 — **트윈을 바꾸는 문은 전부 잠겨 있다.**
3
+ *
4
+ * ── 계기 (2026-08-06) ──────────────────────────────────────────────────────
5
+ * 권한 모델을 손대며 세어 보니 트윈 뮤테이션 24개 중 **22개에 게이트가 없었다.** 인스턴스 삭제와
6
+ * 레퍼런스 자격정보 저장까지 로그인만 하면 누구나 부를 수 있었다. 발견은 사람 눈이었다 — 즉
7
+ * 25번째 뮤테이션이 게이트 없이 들어오면 아무것도 잡지 못한다. 그래서 기계가 센다.
8
+ *
9
+ * ── 왜 문자열을 그대로 비교하나 ─────────────────────────────────────────────
10
+ * "게이트가 있나" 만 보면 `domainOwnerGranted: false` 로 약해지거나 category 가 다른 값으로 갈라져도
11
+ * 통과한다. 게이트는 **한 문장이어야** 서버·클라·관리 화면이 같은 권한을 말한다. 그래서 정본 문장과
12
+ * 글자까지 같은지 본다.
13
+ *
14
+ * ── 조회는 왜 검사하지 않나 (ADR-0027) ──────────────────────────────────────
15
+ * 조회에는 게이트를 두지 않기로 했다 — `twinKpi` 가 같은 판정을 게이트 없이 이미 내주므로, 일부
16
+ * 조회만 잠그면 보안이 아니라 화면 고장이 된다. 이 결정이 바뀌면 아래 "정본 문장" 검사가 먼저
17
+ * 깨진다. 그때는 테스트를 고치지 말고 **ADR 을 먼저 고친다.**
18
+ */
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+
24
+ /** 정본 게이트 — 트윈을 바꾸는 모든 작업이 요구하는 단 하나의 권한. */
25
+ const GATE =
26
+ `@Directive('@privilege(category: "twin", privilege: "mutation", ` + `domainOwnerGranted: true, superUserGranted: true)')`
27
+
28
+ const SERVER = new URL('../server', import.meta.url).pathname
29
+
30
+ function walk(dir: string, out: string[] = []): string[] {
31
+ let entries: string[]
32
+ try {
33
+ entries = readdirSync(dir)
34
+ } catch {
35
+ return out
36
+ }
37
+ for (const e of entries) {
38
+ const p = join(dir, e)
39
+ if (statSync(p).isDirectory()) walk(p, out)
40
+ else if (p.endsWith('.ts') && !p.endsWith('.d.ts')) out.push(p)
41
+ }
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * `@Mutation` 위에 붙은 데코레이터·주석 덩이를 거꾸로 모은다.
47
+ *
48
+ * 데코레이터가 바로 붙어 있다고 가정하지 않는다 — 나중에 다른 데코레이터가 끼어들어도 게이트를
49
+ * 찾아야 한다. 덩이의 끝은 코드(다른 메서드의 `}` 등)를 만나는 자리다.
50
+ */
51
+ function decoratorBlockAbove(lines: string[], at: number): string[] {
52
+ const block: string[] = []
53
+ for (let i = at - 1; i >= 0; i--) {
54
+ const t = lines[i].trim()
55
+ if (!t) continue /* 주석과 데코레이터 사이의 빈 줄은 덩이를 끊지 않는다 */
56
+ const isBlockPart = t.startsWith('@') || t.startsWith('*') || t.startsWith('/*') || t.startsWith('//')
57
+ if (!isBlockPart) break
58
+ block.push(t)
59
+ }
60
+ return block
61
+ }
62
+
63
+ const files = walk(SERVER)
64
+
65
+ test('트윈을 바꾸는 뮤테이션은 전부 정본 게이트를 달고 있다', () => {
66
+ const ungated: string[] = []
67
+ let mutations = 0
68
+
69
+ for (const f of files) {
70
+ const lines = readFileSync(f, 'utf-8').split('\n')
71
+ lines.forEach((line, i) => {
72
+ if (!line.trim().startsWith('@Mutation')) return
73
+ mutations++
74
+ if (!decoratorBlockAbove(lines, i).some(l => l === GATE)) {
75
+ /* 어느 뮤테이션인지 이름으로 말한다 — 줄번호만 주면 고치는 사람이 다시 찾아야 한다. */
76
+ const name = (lines.slice(i, i + 6).join(' ').match(/async\s+(\w+)/) ?? [, '?'])[1]
77
+ ungated.push(`${f.split('/').slice(-2).join('/')}:${i + 1} ${name}`)
78
+ }
79
+ })
80
+ }
81
+
82
+ /* 훑기가 망가져 0건이 되면 "위반 없음" 으로 보인다 — 그 거짓 통과를 막는다. */
83
+ assert.ok(mutations >= 20, `뮤테이션을 ${mutations}개만 찾았다 — 소스 훑기가 망가졌다`)
84
+ assert.deepEqual(
85
+ ungated,
86
+ [],
87
+ `게이트 없는 뮤테이션 ${ungated.length}건 — 로그인만 하면 누구나 부를 수 있다:\n${ungated.join('\n')}`
88
+ )
89
+ })
90
+
91
+ test('권한 문장이 한 벌이다 — 약해지거나 갈라지지 않았다', () => {
92
+ const odd: string[] = []
93
+ for (const f of files) {
94
+ readFileSync(f, 'utf-8')
95
+ .split('\n')
96
+ .forEach((line, i) => {
97
+ const t = line.trim()
98
+ if (!t.startsWith("@Directive('@privilege")) return
99
+ if (t !== GATE) odd.push(`${f.split('/').slice(-2).join('/')}:${i + 1} ${t}`)
100
+ })
101
+ }
102
+ assert.deepEqual(
103
+ odd,
104
+ [],
105
+ `정본과 다른 권한 문장 ${odd.length}건. 게이트를 늘리거나 나누는 것이 의도라면 ` +
106
+ `ADR-0027 을 먼저 고치고 이 검사의 정본을 함께 옮긴다:\n${odd.join('\n')}`
107
+ )
108
+ })
@@ -94,9 +94,73 @@ test('★ withLiveOee: projector 미러(oee 없음) + 누적기 → live 설비
94
94
 
95
95
  // withLiveOee 로 보강 → buildEntityDeltas payload 에 oee 포함(sim 과 동일 형상 = 보드 OEE 컴포넌트 live 렌더).
96
96
  const enriched = withLiveOee(rawLive, acc)
97
- const fk = buildEntityDeltas(enriched).find(d => d.tag === 'fk-1')!.data as any
97
+ const fk = buildEntityDeltas(enriched, 't1').find(d => d.tag === 'fk-1')!.data as any
98
98
  assert.ok(fk.oee, 'withLiveOee 후 설비 data(tag) payload 에 oee 포함 — live OEE 갭 종료')
99
99
  // buildEntityDeltas shapeOee = 정수 퍼센트(0-100), raw ms 제외.
100
100
  assert.ok(fk.oee.overall >= 0 && fk.oee.overall <= 100, 'oee.overall ∈ [0,100] 정수 퍼센트')
101
101
  assert.ok(fk.oee.availability < 100, 'availability < 100 (고장 시간 반영 — 누적기 downMs)')
102
102
  })
103
+
104
+ /*
105
+ * ── 되짚은 화면에도 가동 이력이 있어야 한다 (2026-08-05) ────────────────────
106
+ * OEE 는 원 시스템이 누적을 보내 주지 않아 호스트가 상태 전이를 적분해 만든다. 그런데 그 누적기는
107
+ * **라이브에서만** 돌았고, 저널을 접는 경로는 그 계산을 하지 않았다 — 과거를 되짚으면 모든 설비가
108
+ * "가동 이력이 전혀 없음" 으로 보였다(실 저널: `rosarito-mes` 18대, `hatiolab-wms` 22대가 그랬다).
109
+ *
110
+ * 없는 것은 데이터가 아니라 계산이다: 입력은 저널에 다 있다. 그래서 같은 누적기에 같은 이벤트를
111
+ * 태운다 — 이 테스트는 그 재계산이 라이브와 **같은 값**을 내는지, 그리고 시간여행에서 **그 시점까지만**
112
+ * 세는지를 못 박는다.
113
+ */
114
+ test('★ 저널을 다시 태우면 라이브와 같은 계측이 나온다 — 규칙이 한 벌이므로', () => {
115
+ const T0 = Date.parse('2026-08-05T00:00:00.000Z')
116
+ const ev = (ms: number, id: string, status: string) => ({
117
+ eventId: `e${ms}`,
118
+ eventType: 'equipment.status',
119
+ eventTime: new Date(T0 + ms).toISOString(),
120
+ tenantId: 't',
121
+ data: { equipmentId: id, moverId: id, status }
122
+ })
123
+ const stream = [ev(0, 'fk-1', 'idle'), ev(10_000, 'fk-1', 'busy'), ev(40_000, 'fk-1', 'down'), ev(70_000, 'fk-1', 'idle')]
124
+
125
+ /* 라이브 — 이벤트가 도착하는 대로 누적. */
126
+ const live = new OeeAccumulator()
127
+ for (const e of stream) live.apply(e as any)
128
+
129
+ /* 되짚기 — 저널에서 같은 순서로 다시 태운다. */
130
+ const replayed = new OeeAccumulator()
131
+ for (const e of stream) replayed.apply(e as any)
132
+
133
+ const at = T0 + 70_000
134
+ assert.deepEqual(replayed.oee('fk-1', at), live.oee('fk-1', at), '같은 이벤트·같은 시점이면 같은 값이어야 한다')
135
+ assert.equal(replayed.oee('fk-1', at).downMs, 30_000, '고장 30초가 잡혀야 한다')
136
+ })
137
+
138
+ test('★ 시간여행은 그 시점까지만 센다 — 뒤에 일어난 고장이 과거 화면에 섞이지 않게', () => {
139
+ const T0 = Date.parse('2026-08-05T00:00:00.000Z')
140
+ const ev = (ms: number, status: string) => ({
141
+ eventId: `e${ms}`,
142
+ eventType: 'equipment.status',
143
+ eventTime: new Date(T0 + ms).toISOString(),
144
+ tenantId: 't',
145
+ data: { equipmentId: 'fk-1', moverId: 'fk-1', status }
146
+ })
147
+ const all = [ev(0, 'busy'), ev(30_000, 'down'), ev(60_000, 'idle')]
148
+
149
+ /* 30초 시점까지만 태운다(호스트가 잘라 낸 이벤트만 태우는 것과 같다). */
150
+ const past = new OeeAccumulator()
151
+ for (const e of all.slice(0, 2)) past.apply(e as any)
152
+ assert.equal(past.oee('fk-1', T0 + 30_000).downMs, 0, '아직 고장이 시작된 시점 — 고장 시간은 0 이다')
153
+
154
+ const now = new OeeAccumulator()
155
+ for (const e of all) now.apply(e as any)
156
+ assert.equal(now.oee('fk-1', T0 + 60_000).downMs, 30_000, '지금 화면에서는 고장 30초가 잡힌다')
157
+ })
158
+
159
+ test('★ 계측 시점을 벽시계로 재지 않는다 — 과거 화면의 가용률이 시간이 갈수록 떨어지지 않게', () => {
160
+ const T0 = Date.parse('2026-08-05T00:00:00.000Z')
161
+ const acc = new OeeAccumulator()
162
+ acc.apply({ eventId: 'e1', eventType: 'equipment.status', eventTime: new Date(T0).toISOString(), tenantId: 't', data: { equipmentId: 'fk-1', moverId: 'fk-1', status: 'busy' } } as any)
163
+ const atThen = acc.oee('fk-1', T0 + 10_000)
164
+ const atMuchLater = acc.oee('fk-1', T0 + 10_000_000)
165
+ assert.notDeepEqual(atThen, atMuchLater, '시점을 넘기면 값이 달라진다 — 그래서 그 화면의 시각을 넘겨야 한다')
166
+ })
@@ -0,0 +1,80 @@
1
+ /*
2
+ * 런타임 레지스트리 색인 가드 — **엔진 밖에서 맵을 직접 만지지 않는다.**
3
+ *
4
+ * 계기(2026-08-06): `TwinEngine.instances` 가 `instanceId` 하나로 키를 잡던 시절, 엔진 밖 네 곳이 그 맵을
5
+ * **직접 색인**했다(`TwinEngine.instances[instanceId]`, 심지어 `(TwinEngine as any).instances?.[…]`).
6
+ * 그래서 도메인 확인이 자리마다 제각각이었고 한 곳은 아예 없었다.
7
+ *
8
+ * 키를 `(domainId, instanceId)` 로 고쳤지만, **직접 색인이 남아 있으면 같은 부류가 다시 자란다** — 밖에서
9
+ * `instances[id]` 를 쓰면 이제는 항상 `undefined` 가 나와 조용히 "안 돌고 있다" 로 읽힌다(더 나쁘다:
10
+ * 오류가 아니라 거짓 사실이 된다). 그래서 접근자(`owns`·`runtime`·`kernel`·`metrics`·`snapshot`)만 쓴다.
11
+ *
12
+ * 이 부류는 이미 한 번 물었다 — `twin-lifecycle-mutation.ts` 주석이 증언한다("과거엔
13
+ * `Object.keys(TwinEngine.instances)` 로 전역 반환 → 다른 도메인 트윈이 새던 버그"). 그때는 그 자리만
14
+ * 고쳤다. 이번에는 기계가 지킨다.
15
+ */
16
+ import { test } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+
21
+ const SERVER = new URL('../server', import.meta.url).pathname
22
+
23
+ /** 맵을 소유한 자리 — 여기서는 색인해야 한다. */
24
+ const OWNERS = ['engine/twin-engine.ts', 'engine/runtime-key.ts']
25
+
26
+ function walk(dir: string, out: string[] = []): string[] {
27
+ for (const entry of readdirSync(dir)) {
28
+ const p = join(dir, entry)
29
+ if (statSync(p).isDirectory()) walk(p, out)
30
+ else if (p.endsWith('.ts') && !p.endsWith('.d.ts')) out.push(p)
31
+ }
32
+ return out
33
+ }
34
+
35
+ /** 주석 줄은 뺀다 — 이 결함을 설명하는 주석이 여러 곳에 있고, 그것을 위반으로 셀 수 없다. */
36
+ const isComment = (line: string) => /^\s*(\/\/|\/\*|\*)/.test(line)
37
+
38
+ test('엔진 밖에서 런타임 레지스트리를 직접 색인하지 않는다', () => {
39
+ const offenders: string[] = []
40
+
41
+ for (const file of walk(SERVER)) {
42
+ const rel = file.slice(SERVER.length + 1)
43
+ if (OWNERS.includes(rel)) continue
44
+
45
+ readFileSync(file, 'utf-8')
46
+ .split('\n')
47
+ .forEach((line, i) => {
48
+ if (isComment(line)) return
49
+ /*
50
+ * **엔진 참조가 같은 줄에 있을 때만** 위반이다(`as any` 우회 포함). `.instances[` 만 보면
51
+ * 무관한 배열(응답·지역 변수)까지 잡아 정당한 코드를 막는다.
52
+ */
53
+ if (/TwinEngine\s*(as\s+any\s*)?\)?\s*\.\s*(instances|recovered)/.test(line)) {
54
+ offenders.push(`${rel}:${i + 1} ${line.trim().slice(0, 100)}`)
55
+ }
56
+ })
57
+ }
58
+
59
+ assert.deepEqual(
60
+ offenders,
61
+ [],
62
+ `레지스트리를 직접 색인하는 곳 ${offenders.length}곳 — 접근자(owns·runtime·kernel·metrics·snapshot)를 쓴다:\n` +
63
+ offenders.join('\n')
64
+ )
65
+ })
66
+
67
+ test('엔진이 맵을 만질 때는 항상 키 함수를 거친다', () => {
68
+ const src = readFileSync(join(SERVER, 'engine/twin-engine.ts'), 'utf-8')
69
+ const bad: string[] = []
70
+
71
+ src.split('\n').forEach((line, i) => {
72
+ if (isComment(line)) return
73
+ /* 색인하는 줄에는 `runtimeKey(` 또는 미리 만들어 둔 `key` 변수가 있어야 한다. */
74
+ if (!/this\.(instances|recovered)\s*\[/.test(line)) return
75
+ if (/runtimeKey\(/.test(line) || /\[key\]/.test(line)) return
76
+ bad.push(`engine/twin-engine.ts:${i + 1} ${line.trim().slice(0, 100)}`)
77
+ })
78
+
79
+ assert.deepEqual(bad, [], `키 함수를 거치지 않고 색인하는 곳 ${bad.length}곳:\n${bad.join('\n')}`)
80
+ })