@things-factory/figure-service 10.1.66 → 10.1.68
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.
- package/README.md +5 -8
- package/dist-server/service/figure/figure-format.d.ts +49 -31
- package/dist-server/service/figure/figure-format.js +47 -90
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-history.js +1 -1
- package/dist-server/service/figure/figure-history.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +5 -5
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose-v3.d.ts +1 -1
- package/dist-server/service/figure/figure-propose-v3.js +1 -1
- package/dist-server/service/figure/figure-propose-v3.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +6 -11
- package/dist-server/service/figure/figure-query.js +12 -40
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.js +2 -11
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +32 -112
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +0 -17
- package/dist-server/service/figure/figure-type.js +4 -71
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure-v3-write.d.ts +1 -1
- package/dist-server/service/figure/figure-v3-write.js +2 -3
- package/dist-server/service/figure/figure-v3-write.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +5 -10
- package/dist-server/service/figure/figure.js +6 -11
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/index.js +1 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/figure/figure-blueprint-query.test.ts +29 -48
- package/server/service/figure/figure-format.test.ts +54 -110
- package/server/service/figure/figure-format.ts +77 -87
- package/server/service/figure/figure-history.ts +1 -1
- package/server/service/figure/figure-inspect-db.test.ts +1 -11
- package/server/service/figure/figure-mutation.ts +7 -7
- package/server/service/figure/figure-propose-v3.test.ts +2 -2
- package/server/service/figure/figure-propose-v3.ts +1 -1
- package/server/service/figure/figure-query.ts +14 -36
- package/server/service/figure/figure-rename-db.test.ts +1 -1
- package/server/service/figure/figure-rename.test.ts +4 -18
- package/server/service/figure/figure-rename.ts +2 -12
- package/server/service/figure/figure-tools.test.ts +17 -80
- package/server/service/figure/figure-tools.ts +30 -125
- package/server/service/figure/figure-type.ts +3 -52
- package/server/service/figure/figure-v3-write.ts +3 -4
- package/server/service/figure/figure.ts +7 -12
- package/server/service/index.ts +1 -1
- package/dist-server/service/figure/figure-gate.d.ts +0 -93
- package/dist-server/service/figure/figure-gate.js +0 -302
- package/dist-server/service/figure/figure-gate.js.map +0 -1
- package/dist-server/service/figure/figure-source.d.ts +0 -45
- package/dist-server/service/figure/figure-source.js +0 -59
- package/dist-server/service/figure/figure-source.js.map +0 -1
- package/server/service/figure/figure-gate.test.ts +0 -155
- package/server/service/figure/figure-gate.ts +0 -392
- package/server/service/figure/figure-source.test.ts +0 -149
- package/server/service/figure/figure-source.ts +0 -94
|
@@ -1,392 +0,0 @@
|
|
|
1
|
-
import { blocksRelease, compile, contractChanges, sizingReport, validate } from '@hatiolab/figure-model'
|
|
2
|
-
import type { Axis, FigureSource } from '@hatiolab/figure-model'
|
|
3
|
-
|
|
4
|
-
/** Service transport shape. It deliberately contains only an explicit, reversible FigureSource edit. */
|
|
5
|
-
export interface GateFix {
|
|
6
|
-
findingWay: string
|
|
7
|
-
part: string
|
|
8
|
-
axis: Axis
|
|
9
|
-
from?: string
|
|
10
|
-
to: 'min' | 'center' | 'max' | 'span'
|
|
11
|
-
resolved: number
|
|
12
|
-
/** True only when rechecking every sizing case leaves no release-blocking split. */
|
|
13
|
-
safe: boolean
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A coordinated repair, measured from the current source all the way through the sizing suite.
|
|
18
|
-
* A split often involves several independently anchored parts, so no single anchor edit can be
|
|
19
|
-
* called a solution. This remains a draft-only edit; it is never saved or released by the gate.
|
|
20
|
-
*/
|
|
21
|
-
export interface GateFixPlan {
|
|
22
|
-
fixes: GateFix[]
|
|
23
|
-
resolved: number
|
|
24
|
-
/** True only after the complete plan leaves no release-blocking sizing split. */
|
|
25
|
-
safe: boolean
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 발행해도 되나 — **서버가 판정한다.**
|
|
30
|
-
*
|
|
31
|
-
* ## 왜 서버인가
|
|
32
|
-
*
|
|
33
|
-
* 이 검사는 브라우저 하네스에만 있었다(`test/browser/check-figure-scaling.mjs`). 사람이 돌릴 때만
|
|
34
|
-
* 돌았고, 발행 경로에는 아무 검사도 없었다 — 형식만 맞으면 무엇이든 발행되었다. 발행은 소비처에게
|
|
35
|
-
* 하는 약속이므로, 그 약속을 지킬 수 있는지는 약속하는 자리가 재야 한다.
|
|
36
|
-
*
|
|
37
|
-
* ## 무엇을 막고 무엇을 알리나
|
|
38
|
-
*
|
|
39
|
-
* 판정은 `figure-model` 의 `gate.ts` 가 이미 문서로 정해 두었다. 여기서 새로 정하지 않는다.
|
|
40
|
-
*
|
|
41
|
-
* sizing-splits 크기를 바꾸면 조각이 갈라진다 **막는다**
|
|
42
|
-
* part-outside-base 부품이 기준 상자를 넘는다 **막는다**
|
|
43
|
-
* parts-off-placement-face 기준면에 안 닿는다 **막는다**
|
|
44
|
-
* sizing-loose 1배에서 이미 여러 조각이다 알린다
|
|
45
|
-
* 그 밖의 violations 형식이 권하지 않는 것 알린다 (저장 때 이미 보여 준 것들)
|
|
46
|
-
*
|
|
47
|
-
* 갈라짐을 막는 이유는 사람 눈에 「부서졌다」로 보이기 때문이다 — 시그널타워를 3배로 세우니 램프가
|
|
48
|
-
* 기둥 위 공중에 떴던 그것이고, 앵커 규칙이 생긴 이유다. 소비처가 형상을 늘려 쓰는 것은 막을 수
|
|
49
|
-
* 없으니(그것이 이 형식의 값이다), 늘려도 견디는지를 발행 전에 본다.
|
|
50
|
-
*
|
|
51
|
-
* ## 몇 개가 걸리나는 여기 안 적는다
|
|
52
|
-
*
|
|
53
|
-
* 한동안 「표본 15개 중 갈라짐 1 · 조용함 14」를 적어 두고 **게이트가 벽이 아니라는 근거**로
|
|
54
|
-
* 삼았다. 수는 굳고 카탈로그는 움직인다 — 기준 상자 판정이 붙자 그 비율이 하루 만에 뒤집혔다.
|
|
55
|
-
* 굳은 수를 근거로 든 문장은 그때부터 사실이 아닌 채로 사람을 안심시킨다.
|
|
56
|
-
*
|
|
57
|
-
* 무엇이 걸리고 얼마나 넘쳤나는 **저작 도구가 재서 보여 준다.** 그것을 볼 사람은 고치는
|
|
58
|
-
* 사람이고, 그 사람이 보는 자리는 여기가 아니다.
|
|
59
|
-
*/
|
|
60
|
-
|
|
61
|
-
/** 게이트가 낸 한 가지. `blocking` 이 참이면 발행이 안 된다. */
|
|
62
|
-
export interface GateFinding {
|
|
63
|
-
code: string
|
|
64
|
-
message: string
|
|
65
|
-
/**
|
|
66
|
-
* **왜 그것이 문제인가.** 잰 것만 보여 주면 읽는 사람이 「그래서?」에서 멈춘다 — 화면에
|
|
67
|
-
* 「1 조각이 2 조각으로 갈라진다」만 떴을 때 실제로 그랬다.
|
|
68
|
-
*/
|
|
69
|
-
why?: string
|
|
70
|
-
/** **무엇을 바꾸면 되나.** 부품 이름과 값으로. 형식이 만든 문장을 그대로 옮긴다. */
|
|
71
|
-
how?: string
|
|
72
|
-
/** 어디를 가리키나 — 갈라짐은 갈라진 부품 이름들, 형식 위반은 정본 안의 경로. */
|
|
73
|
-
at?: string
|
|
74
|
-
/**
|
|
75
|
-
* 실제로 재 본, 되돌릴 수 있는 한 앵커 수정안들. 문장을 파싱해서 고치는 것이 아니라
|
|
76
|
-
* FigureSource 에 적용할 정확한 값만 내보낸다. 화면은 이것을 후보로만 보여 주고 저작자가
|
|
77
|
-
* 수락하기 전에는 정본을 바꾸지 않는다.
|
|
78
|
-
*/
|
|
79
|
-
fixes?: GateFix[]
|
|
80
|
-
/** The one-click, fully remeasured repair when individual candidates are insufficient. */
|
|
81
|
-
fixPlan?: GateFixPlan
|
|
82
|
-
blocking: boolean
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface Inspection {
|
|
86
|
-
findings: GateFinding[]
|
|
87
|
-
/** 막는 것이 하나라도 있나. 화면이 이것으로 단추를 잠근다. */
|
|
88
|
-
blocked: boolean
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Figure model publishes the same capability for direct model consumers. Keep the service calculation
|
|
93
|
-
* here as well because this package is deployed independently and receives a compiled local model package.
|
|
94
|
-
* Each alternative is remeasured; prose from `how` is never parsed into an edit.
|
|
95
|
-
*/
|
|
96
|
-
function measuredFixes(source: FigureSource, factor: number): GateFix[] {
|
|
97
|
-
const baseline = sizingReport(compile(source), factor)
|
|
98
|
-
const baselineCount = baseline.findings.filter(finding => finding.code === 'sizing-splits').length
|
|
99
|
-
const seen = new Set<string>()
|
|
100
|
-
const fixes: GateFix[] = []
|
|
101
|
-
|
|
102
|
-
for (const finding of baseline.findings) {
|
|
103
|
-
if (finding.code !== 'sizing-splits' || !finding.way) continue
|
|
104
|
-
const axes = finding.way.split('-').filter((axis): axis is Axis => ['x', 'y', 'z'].includes(axis))
|
|
105
|
-
const names = new Set(finding.clumps.flat())
|
|
106
|
-
|
|
107
|
-
for (const part of source.parts) {
|
|
108
|
-
if (!names.has(part.name)) continue
|
|
109
|
-
for (const axis of axes) {
|
|
110
|
-
for (const to of ['min', 'center', 'max', 'span'] as const) {
|
|
111
|
-
if (part.anchor?.[axis] === to) continue
|
|
112
|
-
const changed: FigureSource = {
|
|
113
|
-
...source,
|
|
114
|
-
parts: source.parts.map(one => one.name === part.name ? { ...one, anchor: { ...(one.anchor ?? {}), [axis]: to } } : one)
|
|
115
|
-
}
|
|
116
|
-
const afterCount = sizingReport(compile(changed), factor).findings.filter(one => one.code === 'sizing-splits').length
|
|
117
|
-
const resolved = baselineCount - afterCount
|
|
118
|
-
const key = `${finding.way}:${part.name}:${axis}:${to}`
|
|
119
|
-
if (resolved <= 0 || seen.has(key)) continue
|
|
120
|
-
seen.add(key)
|
|
121
|
-
fixes.push({
|
|
122
|
-
findingWay: finding.way,
|
|
123
|
-
part: part.name,
|
|
124
|
-
axis,
|
|
125
|
-
from: part.anchor?.[axis],
|
|
126
|
-
to,
|
|
127
|
-
resolved,
|
|
128
|
-
/* 한 finding만 없앤 것이 아니라 전수 검사에서 막는 split이 0일 때만 원클릭 후보가 된다. */
|
|
129
|
-
safe: afterCount === 0
|
|
130
|
-
})
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return fixes.sort((a, b) => b.resolved - a.resolved || a.part.localeCompare(b.part)).slice(0, 12)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function sourceWithFix(source: FigureSource, fix: Pick<GateFix, 'part' | 'axis' | 'to'>): FigureSource {
|
|
140
|
-
return {
|
|
141
|
-
...source,
|
|
142
|
-
parts: source.parts.map(part => part.name === fix.part
|
|
143
|
-
? { ...part, anchor: { ...(part.anchor ?? {}), [fix.axis]: fix.to } }
|
|
144
|
-
: part)
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function splitCount(source: FigureSource, factor: number) {
|
|
149
|
-
return sizingReport(compile(source), factor).findings.filter(finding => finding.code === 'sizing-splits').length
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Find a small coordinated repair by repeatedly choosing the measured anchor edit that removes
|
|
154
|
-
* the most remaining splits. Every step is evaluated against the real compiler; prose is never
|
|
155
|
-
* interpreted. If the complete suite is not clear, no "recommended" button is exposed.
|
|
156
|
-
*/
|
|
157
|
-
function measuredFixPlan(source: FigureSource, factor: number): GateFixPlan | undefined {
|
|
158
|
-
const originalCount = splitCount(source, factor)
|
|
159
|
-
if (originalCount === 0) return undefined
|
|
160
|
-
|
|
161
|
-
let working = source
|
|
162
|
-
let remaining = originalCount
|
|
163
|
-
const used = new Set<string>()
|
|
164
|
-
const fixes: GateFix[] = []
|
|
165
|
-
|
|
166
|
-
for (let step = 0; step < source.parts.length * 3 && remaining > 0; step++) {
|
|
167
|
-
const report = sizingReport(compile(working), factor)
|
|
168
|
-
let best: GateFix | undefined
|
|
169
|
-
|
|
170
|
-
for (const finding of report.findings) {
|
|
171
|
-
if (finding.code !== 'sizing-splits' || !finding.way) continue
|
|
172
|
-
const axes = finding.way.split('-').filter((axis): axis is Axis => ['x', 'y', 'z'].includes(axis))
|
|
173
|
-
const names = new Set(finding.clumps.flat())
|
|
174
|
-
|
|
175
|
-
for (const part of working.parts) {
|
|
176
|
-
if (!names.has(part.name)) continue
|
|
177
|
-
for (const axis of axes) {
|
|
178
|
-
const key = `${part.name}:${axis}`
|
|
179
|
-
if (used.has(key)) continue
|
|
180
|
-
for (const to of ['min', 'center', 'max', 'span'] as const) {
|
|
181
|
-
if (part.anchor?.[axis] === to) continue
|
|
182
|
-
const afterCount = splitCount(sourceWithFix(working, { part: part.name, axis, to }), factor)
|
|
183
|
-
const resolved = remaining - afterCount
|
|
184
|
-
if (resolved <= 0) continue
|
|
185
|
-
const candidate: GateFix = {
|
|
186
|
-
findingWay: finding.way,
|
|
187
|
-
part: part.name,
|
|
188
|
-
axis,
|
|
189
|
-
from: source.parts.find(one => one.name === part.name)?.anchor?.[axis],
|
|
190
|
-
to,
|
|
191
|
-
resolved,
|
|
192
|
-
safe: false
|
|
193
|
-
}
|
|
194
|
-
if (!best || candidate.resolved > best.resolved ||
|
|
195
|
-
(candidate.resolved === best.resolved && `${candidate.part}:${candidate.axis}:${candidate.to}` < `${best.part}:${best.axis}:${best.to}`)) {
|
|
196
|
-
best = candidate
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (!best) break
|
|
204
|
-
fixes.push(best)
|
|
205
|
-
used.add(`${best.part}:${best.axis}`)
|
|
206
|
-
working = sourceWithFix(working, best)
|
|
207
|
-
remaining = splitCount(working, factor)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (remaining === 0 && fixes.length > 0) {
|
|
211
|
-
return { fixes: fixes.map(fix => ({ ...fix, safe: true })), resolved: originalCount, safe: true }
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/*
|
|
215
|
-
* Some assemblies need two anchors to move together. Neither first move removes a whole split,
|
|
216
|
-
* so greedy improvement alone cannot see the repair. Search a small, deterministic beam of those
|
|
217
|
-
* coordinated moves, and still return only a plan that the compiler proves clear.
|
|
218
|
-
*/
|
|
219
|
-
const firstReport = sizingReport(compile(source), factor)
|
|
220
|
-
const candidates = new Map<string, { part: string; axis: Axis }>()
|
|
221
|
-
for (const finding of firstReport.findings) {
|
|
222
|
-
if (finding.code !== 'sizing-splits' || !finding.way) continue
|
|
223
|
-
const axes = finding.way.split('-').filter((axis): axis is Axis => ['x', 'y', 'z'].includes(axis))
|
|
224
|
-
const names = new Set(finding.clumps.flat())
|
|
225
|
-
for (const part of source.parts) {
|
|
226
|
-
if (!names.has(part.name)) continue
|
|
227
|
-
for (const axis of axes) candidates.set(`${part.name}:${axis}`, { part: part.name, axis })
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
type SearchState = { source: FigureSource; fixes: GateFix[]; used: Set<string>; remaining: number }
|
|
232
|
-
let frontier: SearchState[] = [{ source, fixes: [], used: new Set(), remaining: originalCount }]
|
|
233
|
-
const visited = new Set<string>()
|
|
234
|
-
for (let depth = 0; depth < 8 && frontier.length; depth++) {
|
|
235
|
-
const next: SearchState[] = []
|
|
236
|
-
for (const state of frontier) {
|
|
237
|
-
for (const [key, target] of candidates) {
|
|
238
|
-
if (state.used.has(key)) continue
|
|
239
|
-
const current = state.source.parts.find(part => part.name === target.part)
|
|
240
|
-
if (!current) continue
|
|
241
|
-
for (const to of ['min', 'center', 'max', 'span'] as const) {
|
|
242
|
-
if (current.anchor?.[target.axis] === to) continue
|
|
243
|
-
const changed = sourceWithFix(state.source, { ...target, to })
|
|
244
|
-
const signature = JSON.stringify(changed.parts.map(part => [part.name, part.anchor]))
|
|
245
|
-
if (visited.has(signature)) continue
|
|
246
|
-
visited.add(signature)
|
|
247
|
-
const after = splitCount(changed, factor)
|
|
248
|
-
const fix: GateFix = {
|
|
249
|
-
findingWay: 'coordinated', part: target.part, axis: target.axis,
|
|
250
|
-
from: source.parts.find(part => part.name === target.part)?.anchor?.[target.axis], to,
|
|
251
|
-
resolved: state.remaining - after, safe: false
|
|
252
|
-
}
|
|
253
|
-
const fixes = [...state.fixes, fix]
|
|
254
|
-
if (after === 0) return { fixes: fixes.map(one => ({ ...one, safe: true })), resolved: originalCount, safe: true }
|
|
255
|
-
next.push({ source: changed, fixes, used: new Set([...state.used, key]), remaining: after })
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
/* Keep enough alternatives to discover a paired repair without making release inspection expensive. */
|
|
260
|
-
frontier = next.sort((a, b) => a.remaining - b.remaining || a.fixes.length - b.fixes.length ||
|
|
261
|
-
JSON.stringify(a.fixes).localeCompare(JSON.stringify(b.fixes))).slice(0, 24)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return undefined
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* 정본 하나를 판정한다.
|
|
269
|
-
*
|
|
270
|
-
* 정본이 없거나(아직 저장 전) JSON 이 아니면 **막는다** — 발행할 대상이 없는 것이다. 그 경우를
|
|
271
|
-
* 조용히 통과시키면 빈 것이 발행된다.
|
|
272
|
-
*
|
|
273
|
-
* `releasedJson` 은 **직전 발행판의 정본**이다. 주면 그것과 견주어 「이미 놓인 인스턴스를 깨는
|
|
274
|
-
* 발행인가」를 함께 낸다(ADR-0063 결정 3). 한 번도 발행된 적 없으면 견줄 것이 없으므로 안 준다.
|
|
275
|
-
*/
|
|
276
|
-
export function inspectSource(sourceJson?: string | null, releasedJson?: string | null): Inspection {
|
|
277
|
-
if (!sourceJson) {
|
|
278
|
-
return {
|
|
279
|
-
blocked: true,
|
|
280
|
-
findings: [
|
|
281
|
-
{
|
|
282
|
-
code: 'no-source',
|
|
283
|
-
message: '저장된 정본이 없다. 저장하면 발행할 수 있다',
|
|
284
|
-
blocking: true
|
|
285
|
-
}
|
|
286
|
-
]
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
let parsed: unknown
|
|
291
|
-
try {
|
|
292
|
-
parsed = JSON.parse(sourceJson)
|
|
293
|
-
} catch (e) {
|
|
294
|
-
return {
|
|
295
|
-
blocked: true,
|
|
296
|
-
findings: [{ code: 'source-not-json', message: `정본이 JSON 이 아니다: ${(e as Error).message}`, blocking: true }]
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const result = validate(parsed)
|
|
301
|
-
const findings: GateFinding[] = []
|
|
302
|
-
|
|
303
|
-
/*
|
|
304
|
-
형식 오류는 **저장을 통과할 수 없다**(`parseSource` 가 거절한다). 그래도 여기서 다시 보는 이유는,
|
|
305
|
-
이 판정이 저장 경로와 독립이어야 하기 때문이다 — 옛 정본이 남아 있거나 형식이 엄해진 뒤라면
|
|
306
|
-
저장 때는 맞았던 것이 지금은 틀릴 수 있다. 그때 발행이 통과하면 안 된다.
|
|
307
|
-
*/
|
|
308
|
-
for (const error of result.errors) {
|
|
309
|
-
findings.push({ code: error.code, message: error.message, at: error.path, blocking: true })
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
for (const violation of result.violations) {
|
|
313
|
-
findings.push({
|
|
314
|
-
code: violation.code,
|
|
315
|
-
message: violation.message,
|
|
316
|
-
/*
|
|
317
|
-
왜·어떻게는 형식이 만든 것을 그대로 옮긴다. 여기서 서버가 다시 쓰면 두 벌이 되고 곧 어긋난다 —
|
|
318
|
-
크기 반응 판정이 이미 그 규율이다(아래 `report.findings`).
|
|
319
|
-
|
|
320
|
-
이 두 줄이 없던 동안 발행 화면은 막는 위반에 문장 하나만 보여 줬다. 표본 47 개를 게이트에 넣어
|
|
321
|
-
보니 막는 판정 99 건 중 왜·어떻게를 가진 것이 2 건이었고, 나머지 97 건은 저작자가 상자를 키울지
|
|
322
|
-
부품을 옮길지 알 길이 없었다(2026-09-19, figure-model 0.1.29 가 그 값을 싣기 시작했다).
|
|
323
|
-
*/
|
|
324
|
-
why: violation.why,
|
|
325
|
-
how: violation.how,
|
|
326
|
-
/* 어느 위반이 막나는 형식이 안다 — 여기 목록을 또 들면 둘이 갈린다(ADR-0044 결정 ②). */
|
|
327
|
-
blocking: blocksRelease(violation.code)
|
|
328
|
-
})
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (result.errors.length === 0) {
|
|
332
|
-
const source = parsed as FigureSource
|
|
333
|
-
const report = sizingReport(compile(source))
|
|
334
|
-
const fixes = measuredFixes(source, report.factor)
|
|
335
|
-
const fixPlan = measuredFixPlan(source, report.factor)
|
|
336
|
-
|
|
337
|
-
for (const finding of report.findings) {
|
|
338
|
-
findings.push({
|
|
339
|
-
code: finding.code,
|
|
340
|
-
message: finding.message,
|
|
341
|
-
/* 왜·어떻게는 형식이 소유한다. 서버가 다시 쓰면 두 벌이 되고 곧 어긋난다. */
|
|
342
|
-
why: finding.why,
|
|
343
|
-
how: finding.how,
|
|
344
|
-
/* 숫자가 아니라 자리를 말한다 — 어느 부품들이 갈라져 나왔나. */
|
|
345
|
-
at: finding.clumps.map(clump => [...clump].sort().join('+')).join(' | ') || undefined,
|
|
346
|
-
fixes: finding.code === 'sizing-splits' ? fixes.filter(fix => fix.findingWay === finding.way) : undefined,
|
|
347
|
-
/* A plan is attached once: it clears the full suite, not merely this card's symptom. */
|
|
348
|
-
fixPlan: finding.code === 'sizing-splits' && finding === report.findings.find(one => one.code === 'sizing-splits') ? fixPlan : undefined,
|
|
349
|
-
blocking: finding.code === 'sizing-splits'
|
|
350
|
-
})
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
findings.push(...contractFindings(source, releasedJson))
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
return { findings, blocked: findings.some(finding => finding.blocking) }
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* 직전 발행판과 견준 것 — **약속을 깨는 발행인가**(ADR-0063 결정 3).
|
|
361
|
-
*
|
|
362
|
-
* 무엇이 달라졌고 그것이 막는 것인지는 형식이 정한다(`contractChanges`). 여기서는 화면과 발행 문이
|
|
363
|
-
* 읽는 모양으로 옮기기만 한다.
|
|
364
|
-
*
|
|
365
|
-
* **앞 판을 오늘 형식으로 못 읽으면 조용히 넘어가지 않는다.** 판 1 은 오늘 형식이 거절하는 판으로
|
|
366
|
-
* 적혀 있을 수 있고, 그때 견주기를 건너뛰면 아무 말 없이 관문이 없는 것이 된다. 막지는 않는다 —
|
|
367
|
-
* 그러면 그 도형은 영영 다시 발행할 수 없다.
|
|
368
|
-
*/
|
|
369
|
-
function contractFindings(source: FigureSource, releasedJson?: string | null): GateFinding[] {
|
|
370
|
-
if (!releasedJson) return []
|
|
371
|
-
|
|
372
|
-
let released: FigureSource
|
|
373
|
-
try {
|
|
374
|
-
released = JSON.parse(releasedJson) as FigureSource
|
|
375
|
-
return contractChanges(released, source).map(change => ({
|
|
376
|
-
code: change.code,
|
|
377
|
-
message: change.message,
|
|
378
|
-
why: change.why,
|
|
379
|
-
at: change.subject,
|
|
380
|
-
blocking: change.blocking
|
|
381
|
-
}))
|
|
382
|
-
} catch (e) {
|
|
383
|
-
return [
|
|
384
|
-
{
|
|
385
|
-
code: 'released-unreadable',
|
|
386
|
-
message: `직전 발행판을 오늘 형식으로 읽을 수 없어 견주지 못했다: ${(e as Error).message}`,
|
|
387
|
-
why: '이 발행이 이미 놓인 인스턴스를 깨는지는 확인되지 않았다.',
|
|
388
|
-
blocking: false
|
|
389
|
-
}
|
|
390
|
-
]
|
|
391
|
-
}
|
|
392
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* figure-source — 정본이 들어오는 문에서 무엇을 막고 무엇을 알리나.
|
|
3
|
-
*
|
|
4
|
-
* 이 파일이 지키는 것은 하나다. **서버가 형식을 어긴 정본을 저장하지 않는다.**
|
|
5
|
-
* 저작 화면이 검증하니 서버는 믿어도 된다는 말이 안 되는 이유는, 저작 화면을 거치지
|
|
6
|
-
* 않는 길(가져오기·API·AI 어시스턴트)이 있기 때문이다.
|
|
7
|
-
*
|
|
8
|
-
* 그리고 파생값(`score`·`triangles`·`groups`)을 **클라이언트에서 받지 않는다**는 것도
|
|
9
|
-
* 여기서 지킨다. 받으면 목록 화면이 실물과 다른 것을 보여 준다.
|
|
10
|
-
*/
|
|
11
|
-
import { FigureSourceError, parseSource } from './figure-source'
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 온전한 부품 하나. 시험할 필드만 덮어쓴다.
|
|
15
|
-
*
|
|
16
|
-
* `y: -350` 은 `source()` 의 상자(높이 800)의 밑면 -400 에 딱 붙어 서는 자리다. 바닥 기반
|
|
17
|
-
* 도형이 상자 밑면에 안 닿으면 `parts-off-placement-face` 가 난다 — 픽스처가 그걸 달고
|
|
18
|
-
* 있으면 「위반이 없다」를 보는 시험이 그 하나 때문에 빨개진다.
|
|
19
|
-
*/
|
|
20
|
-
function part(over: Record<string, unknown> = {}) {
|
|
21
|
-
return {
|
|
22
|
-
name: 'body',
|
|
23
|
-
primitive: 'cube',
|
|
24
|
-
transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
25
|
-
material: { token: 'palette.primary' },
|
|
26
|
-
...over
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** 온전한 정본 하나. */
|
|
31
|
-
function source(over: Record<string, unknown> = {}) {
|
|
32
|
-
return {
|
|
33
|
-
version: 2,
|
|
34
|
-
type: 'MIXER',
|
|
35
|
-
base: { x: 300, y: 800, z: 300 },
|
|
36
|
-
detailLevel: 'M',
|
|
37
|
-
parts: [part()],
|
|
38
|
-
...over
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const json = (v: unknown) => JSON.stringify(v)
|
|
43
|
-
|
|
44
|
-
describe('figure-source — 들어오는 문', () => {
|
|
45
|
-
describe('막는다', () => {
|
|
46
|
-
it('JSON 이 아니면 던진다 — 무엇이 잘못됐는지 말한다', () => {
|
|
47
|
-
let thrown: FigureSourceError | undefined
|
|
48
|
-
try {
|
|
49
|
-
parseSource('{ 이건 JSON 이 아니다')
|
|
50
|
-
} catch (e) {
|
|
51
|
-
thrown = e as FigureSourceError
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
expect(thrown).toBeInstanceOf(FigureSourceError)
|
|
55
|
-
expect(thrown!.findings[0].code).toBe('source-not-json')
|
|
56
|
-
// 조용히 빈 값으로 넘기지 않는다
|
|
57
|
-
expect(thrown!.findings[0].message).toContain('JSON')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('객체가 아니면 던진다', () => {
|
|
61
|
-
for (const bad of ['null', '42', '"x"', '[]']) {
|
|
62
|
-
expect(() => parseSource(bad)).toThrow(FigureSourceError)
|
|
63
|
-
}
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('형식을 어기면 던지고, figure-model 의 코드를 그대로 전한다', () => {
|
|
67
|
-
let thrown: FigureSourceError | undefined
|
|
68
|
-
try {
|
|
69
|
-
parseSource(json(source({ parts: [] })))
|
|
70
|
-
} catch (e) {
|
|
71
|
-
thrown = e as FigureSourceError
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
expect(thrown).toBeInstanceOf(FigureSourceError)
|
|
75
|
-
expect(thrown!.findings[0].code).toBe('missing-parts')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('필수 필드가 빠지면 던진다', () => {
|
|
79
|
-
// type 없음
|
|
80
|
-
expect(() => parseSource(json({ base: { x: 1, y: 1, z: 1 }, parts: [part()] }))).toThrow(FigureSourceError)
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('오류의 위치(at)를 알린다 — 어느 부품인지 저작 화면이 짚을 수 있다', () => {
|
|
84
|
-
let thrown: FigureSourceError | undefined
|
|
85
|
-
try {
|
|
86
|
-
parseSource(json(source({ parts: [part({ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: -1, y: 1, z: 1 } } })] })))
|
|
87
|
-
} catch (e) {
|
|
88
|
-
thrown = e as FigureSourceError
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
expect(thrown).toBeInstanceOf(FigureSourceError)
|
|
92
|
-
expect(thrown!.findings.some(f => !!f.at)).toBe(true)
|
|
93
|
-
})
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
describe('통과시킨다', () => {
|
|
97
|
-
it('온전한 정본은 그대로 통과한다', () => {
|
|
98
|
-
const result = parseSource(json(source()))
|
|
99
|
-
expect(result.violations).toEqual([])
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('정본을 다시 직렬화하지 않는다 — 들어온 문자열을 그대로 저장한다', () => {
|
|
103
|
-
// 이미 version이 있는 정본은 키 순서·공백을 그대로 보존한다
|
|
104
|
-
const raw = json(source())
|
|
105
|
-
expect(parseSource(raw).source).toBe(raw)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
/*
|
|
109
|
-
A source without a version was written before edition 2, with y measured from the box centre.
|
|
110
|
-
It is refused rather than stamped with the current edition, which would move every part.
|
|
111
|
-
*/
|
|
112
|
-
it('refuses a source without a version instead of stamping the current edition on it', () => {
|
|
113
|
-
const withoutVersion = json({ type: 'MIXER', base: { x: 300, y: 800, z: 300 }, detailLevel: 'M', parts: [part()] })
|
|
114
|
-
expect(() => parseSource(withoutVersion)).toThrow(FigureSourceError)
|
|
115
|
-
try {
|
|
116
|
-
parseSource(withoutVersion)
|
|
117
|
-
} catch (e) {
|
|
118
|
-
expect((e as FigureSourceError).findings.map(one => one.code)).toContain('source-version-unsupported')
|
|
119
|
-
}
|
|
120
|
-
})
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
describe('파생값은 서버가 계산한다', () => {
|
|
124
|
-
it('점수·삼각형 수·묶음 수가 채워진다', () => {
|
|
125
|
-
const { derived } = parseSource(json(source()))
|
|
126
|
-
|
|
127
|
-
expect(typeof derived.score).toBe('number')
|
|
128
|
-
expect(derived.score).toBeGreaterThanOrEqual(0)
|
|
129
|
-
expect(derived.score).toBeLessThanOrEqual(100)
|
|
130
|
-
expect(derived.triangles).toBeGreaterThan(0)
|
|
131
|
-
expect(derived.groups).toBeGreaterThan(0)
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
it('클라이언트가 보낸 값은 쓰이지 않는다 — 정본만 본다', () => {
|
|
135
|
-
// 정본 안에 거짓 파생값을 심어도 셈은 부품에서 나온다
|
|
136
|
-
const lying = json(source({ score: 100, triangles: 1, groups: 1 } as Record<string, unknown>))
|
|
137
|
-
const honest = json(source())
|
|
138
|
-
|
|
139
|
-
expect(parseSource(lying).derived).toEqual(parseSource(honest).derived)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('부품이 늘면 삼각형 수도 는다 — 셈이 정본을 실제로 읽는다', () => {
|
|
143
|
-
const one = parseSource(json(source())).derived.triangles
|
|
144
|
-
const two = parseSource(json(source({ parts: [part(), part({ name: 'lid' })] }))).derived.triangles
|
|
145
|
-
|
|
146
|
-
expect(two).toBeGreaterThan(one)
|
|
147
|
-
})
|
|
148
|
-
})
|
|
149
|
-
})
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
|
|
2
|
-
|
|
3
|
-
import { FigureFinding } from './figure-type.js'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* 정본을 받아 **검증하고 파생값을 계산한다.**
|
|
7
|
-
*
|
|
8
|
-
* ## 왜 서버가 검증하나
|
|
9
|
-
*
|
|
10
|
-
* 저작 화면을 못 믿어서가 아니다. **저작 화면을 거치지 않는 길이 있다** — 가져오기,
|
|
11
|
-
* API 직접 호출, AI 어시스턴트. 그 길로 들어온 정본이 형식을 어기면 도면에서 조용히
|
|
12
|
-
* 안 그려진다. 들어오는 문에서 막는다.
|
|
13
|
-
*
|
|
14
|
-
* ## 막는 것과 알리는 것
|
|
15
|
-
*
|
|
16
|
-
* `figure-model` 이 두 단계로 나눠 놓았고, 서버가 그 경계를 다시 정하지 않는다.
|
|
17
|
-
*
|
|
18
|
-
* errors 형식을 어긴 것. **거절한다**
|
|
19
|
-
* violations 한도를 넘은 것. 거절하지 않고 **함께 돌려준다**
|
|
20
|
-
*
|
|
21
|
-
* ## 파생값은 서버가 계산한다
|
|
22
|
-
*
|
|
23
|
-
* `score`·`triangles`·`groups` 를 클라이언트에서 받지 않는다. 받으면 거짓이 들어오고
|
|
24
|
-
* 목록 화면이 실물과 다른 것을 보여 준다. 정본에서 계산한다.
|
|
25
|
-
*/
|
|
26
|
-
export interface ParsedSource {
|
|
27
|
-
/** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 단, version 누락 시에만 정규화 직렬화한다. */
|
|
28
|
-
source: string
|
|
29
|
-
/** 목록 화면이 정렬·필터에 쓰는 파생값. */
|
|
30
|
-
derived: { score: number; triangles: number; groups: number }
|
|
31
|
-
/** 막지 않는 발견. 호출한 쪽이 응답에 실어 보낸다. */
|
|
32
|
-
violations: FigureFinding[]
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** 정본이 형식을 어겼다. 저장하지 않는다. */
|
|
36
|
-
export class FigureSourceError extends Error {
|
|
37
|
-
readonly findings: FigureFinding[]
|
|
38
|
-
|
|
39
|
-
constructor(findings: FigureFinding[]) {
|
|
40
|
-
super(`정본이 형식을 어겼다 — ${findings.length}건: ${findings.map(f => f.code).join(', ')}`)
|
|
41
|
-
this.name = 'FigureSourceError'
|
|
42
|
-
this.findings = findings
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 정본 JSON 문자열을 검증하고 파생값을 계산한다.
|
|
48
|
-
*
|
|
49
|
-
* @throws {FigureSourceError} JSON 이 아니거나 형식을 어긴 경우
|
|
50
|
-
*/
|
|
51
|
-
export function parseSource(sourceJson: string): ParsedSource {
|
|
52
|
-
let parsed: unknown
|
|
53
|
-
try {
|
|
54
|
-
parsed = JSON.parse(sourceJson)
|
|
55
|
-
} catch (e) {
|
|
56
|
-
throw new FigureSourceError([
|
|
57
|
-
{ code: 'source-not-json', message: `정본이 JSON 이 아니다: ${(e as Error).message}`, at: 'source' }
|
|
58
|
-
])
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const result = validate(parsed)
|
|
62
|
-
if (result.errors.length > 0) {
|
|
63
|
-
throw new FigureSourceError(
|
|
64
|
-
result.errors.map(e => ({ code: e.code, message: e.message, at: e.path }))
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/*
|
|
69
|
-
No version is stamped here. validate has already refused a source without one: every source saved
|
|
70
|
-
without a version was written with y measured from the box centre, and stamping the current
|
|
71
|
-
edition on it would move every part (ADR-0065).
|
|
72
|
-
*/
|
|
73
|
-
const normalizedSource = sourceJson
|
|
74
|
-
|
|
75
|
-
// 여기까지 왔으면 형식은 맞다. 청사진을 만들어 셈에 쓴다.
|
|
76
|
-
// 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
|
|
77
|
-
const blueprint = compile(parsed as any)
|
|
78
|
-
const cost = costOf(blueprint, 1)
|
|
79
|
-
const score = scoreOf(blueprint)
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
source: normalizedSource,
|
|
83
|
-
derived: {
|
|
84
|
-
score: score.score,
|
|
85
|
-
triangles: cost.triangles,
|
|
86
|
-
groups: cost.groups
|
|
87
|
-
},
|
|
88
|
-
violations: result.violations.map(v => ({
|
|
89
|
-
code: v.code,
|
|
90
|
-
message: v.message,
|
|
91
|
-
at: undefined
|
|
92
|
-
}))
|
|
93
|
-
}
|
|
94
|
-
}
|