@things-factory/figure-service 10.1.38 → 10.1.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.38",
3
+ "version": "10.1.39",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,13 +25,13 @@
25
25
  "dependencies": {
26
26
  "@hatiolab/figure-model": "^0.1.38",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
- "@things-factory/ai-client-base": "^10.1.31",
29
- "@things-factory/auth-base": "^10.1.38",
28
+ "@things-factory/ai-client-base": "^10.1.39",
29
+ "@things-factory/auth-base": "^10.1.39",
30
30
  "@things-factory/env": "^10.1.20",
31
- "@things-factory/shell": "^10.1.38",
31
+ "@things-factory/shell": "^10.1.39",
32
32
  "graphql-scalars": "^1.22.4",
33
33
  "graphql-upload": "^15.0.2",
34
34
  "koa-router": "^7.4.0"
35
35
  },
36
- "gitHead": "b947349fe1c27d11038b592ceadaba336f14785b"
36
+ "gitHead": "fe5257ad8995fe7e79275e287f36e95b5fd32d1c"
37
37
  }
@@ -10,7 +10,7 @@
10
10
  * - V3 자산도 저장·발행·되돌리기·소비처를 지난다. 이 넷은 전에 V2 만 지날 수 있었다.
11
11
  * - 예산 칸(`triangles`·`groups`)은 두 판에서 같은 뜻이다.
12
12
  */
13
- import { createV3Asset, addV3Box, addV3Primitive, serializeV3Asset, applyV3Authoring, proposeV3Occupancy } from '@hatiolab/figure-model'
13
+ import { createV3Asset, addV3Box, addV3Primitive, serializeV3Asset, applyV3Authoring, proposeV3Occupancy, placeholderType } from '@hatiolab/figure-model'
14
14
  import type { V3Asset } from '@hatiolab/figure-model'
15
15
 
16
16
  import { consumablePayloadOf, formatOf, inspectAnySource, parseAnySource } from './figure-format'
@@ -219,6 +219,14 @@ describe('figure-format — 판을 알아보는 문', () => {
219
219
  expect(finding.how).toContain('부품에 맞추기')
220
220
  })
221
221
 
222
+ it('부품이 없는 새 도형에는 도형을 먼저 추가하라고 한다 — 화면은 그때 맞추기 단추를 내지 않는다', () => {
223
+ const empty = serializeV3Asset(createV3Asset('Empty'))
224
+ const finding = inspectAnySource(empty, null, translatorOf(KO)).findings.find(f => f.code === 'occupancy-undeclared')!
225
+
226
+ expect(finding.how).toBe('먼저 도형을 추가한 뒤, 「부품에 맞추기」로 공간을 선언하세요.')
227
+ expect(inspectAnySource(empty).findings.find(f => f.code === 'occupancy-undeclared')!.how).toMatch(/^Add a shape first/)
228
+ })
229
+
222
230
  it('번역이 없으면 영어로 답한다 — 키를 찍지 않는다', () => {
223
231
  const finding = inspectAnySource(outside()).findings.find(f => f.code === 'part-outside-occupancy')!
224
232
 
@@ -226,6 +234,22 @@ describe('figure-format — 판을 알아보는 문', () => {
226
234
  expect(finding.message).not.toContain('figure.gate.')
227
235
  })
228
236
 
237
+ /*
238
+ V3 자산에는 type 이 없어 V2 의 임시 이름 검사가 돌지 않았고, 새 V3 도형이 `FIGURE_<uuid>` 로 발행됐다
239
+ (:3300, 2026-09-24). 발행하면 이름이 굳으므로 행의 type 으로 같은 거절을 한다.
240
+ */
241
+ it('임시 이름인 V3 도형은 발행을 막는다 — 이름은 행에 있다', () => {
242
+ /* The name a new V3 figure gets — the format's rule, the one the gate recognises. */
243
+ const placeholder = placeholderType()
244
+ const finding = inspectAnySource(v3Source(), null, translatorOf(KO), placeholder).findings.find(f => f.code === 'type-is-placeholder')
245
+
246
+ expect(finding?.blocking).toBe(true)
247
+ expect(finding!.message).toBe(`'${placeholder}' 은 처음 저장할 때 붙은 임시 타입입니다.`)
248
+ /* 화면의 칸 이름은 「타입」이다(`figure.label.figure-type`). */
249
+ expect(finding!.how).toContain('타입 칸')
250
+ expect(inspectAnySource(v3Source(), null, translatorOf(KO), 'ROLLER_CONVEYOR').blocked).toBe(false)
251
+ })
252
+
229
253
  /* 코드가 묻는 키가 두 언어에 다 있어야 한다 — 없으면 그 언어의 화면에 영어가 섞인다. */
230
254
  it('판정이 묻는 키는 ko · en 에 다 있다', () => {
231
255
  const asked: string[] = []
@@ -233,6 +257,7 @@ describe('figure-format — 판을 알아보는 문', () => {
233
257
  inspectAnySource(outside(), null, t)
234
258
  inspectAnySource(bare(), null, t)
235
259
  inspectAnySource(v3Source(), v3Source(), t)
260
+ inspectAnySource(v3Source(), null, t, 'FIGURE_ABCDEF12')
236
261
 
237
262
  const keys = [...new Set(asked)]
238
263
  expect(keys.length).toBeGreaterThan(5)
@@ -20,7 +20,7 @@
20
20
  * No conversion. This door reads what is stored; turning a V2 asset into a V3 one is `convertV2ToV3`, run
21
21
  * deliberately against a recoverable copy (the designer's step 3), never as a side effect of a save.
22
22
  */
23
- import { compile, compileV3Asset, inspectV3Asset, parseV3Asset, serializeV3Asset, v3CostOf } from '@hatiolab/figure-model'
23
+ import { compile, compileV3Asset, inspectV3Asset, isPlaceholderType, parseV3Asset, serializeV3Asset, v3CostOf } from '@hatiolab/figure-model'
24
24
  import type { V3ContractError, V3Violation } from '@hatiolab/figure-model'
25
25
 
26
26
  import type { GateFinding, Inspection } from './figure-gate.js'
@@ -31,7 +31,7 @@ import type { ParsedSource } from './figure-source.js'
31
31
  export type FigureFormat = 'v2' | 'v3'
32
32
 
33
33
  /** The version each format stamps on its own source. */
34
- const V3_ASSET_VERSION = 'figure-v3-asset-1'
34
+ export const V3_ASSET_VERSION = 'figure-v3-asset-1'
35
35
 
36
36
  /**
37
37
  * Which format this source is written in, read from the `version` the asset carries.
@@ -114,7 +114,7 @@ export type Translate = (key: string, options?: Record<string, unknown>) => stri
114
114
  * (`figure.text.mounting-face-explained`). The same register as the V2 blockers since 2026-09-19 — a fact, then
115
115
  * the way out — because at ADR-0087 step 5 these become the only findings an author sees.
116
116
  */
117
- function v3Wording(violation: V3Violation, say: Say): Pick<GateFinding, 'message' | 'why' | 'how'> {
117
+ function v3Wording(violation: V3Violation, say: Say, hasParts: boolean): Pick<GateFinding, 'message' | 'why' | 'how'> {
118
118
  const part = violation.part ?? ''
119
119
  const axis = (violation.axis ?? '').toUpperCase()
120
120
  const mm = violation.mm ?? 0
@@ -138,7 +138,14 @@ function v3Wording(violation: V3Violation, say: Say): Pick<GateFinding, 'message
138
138
  : {
139
139
  message: say(key('message'), {}, 'This figure has not declared the space it takes up.'),
140
140
  why: say(key('why'), {}, 'Without a declared space the scale a figure is placed at cannot be set.'),
141
- how: say(key('how'), {}, 'Use "fit to parts" to declare the space from the parts as they are now.')
141
+ /*
142
+ A new figure is refused this way before it has a part, and 「fit to parts」 has nothing to measure then —
143
+ the screen does not offer it. Telling the author to press it sent them looking for a button that was not
144
+ there (:3300, 2026-09-24).
145
+ */
146
+ how: hasParts
147
+ ? say(key('how'), {}, 'Use "fit to parts" to declare the space from the parts as they are now.')
148
+ : say(key('how-no-parts'), {}, 'Add a shape first, then use "fit to parts" to declare the space from it.')
142
149
  }
143
150
  case 'PARTS_OFF_PLACEMENT_FACE':
144
151
  return {
@@ -161,7 +168,13 @@ function sayerOf(t?: Translate): Say {
161
168
  return (key, params, english) => (t ? t(key, { ...params, defaultValue: english }) : english)
162
169
  }
163
170
 
164
- export function inspectAnySource(sourceJson?: string | null, releasedJson?: string | null, t?: Translate): Inspection {
171
+ export function inspectAnySource(
172
+ sourceJson?: string | null,
173
+ releasedJson?: string | null,
174
+ t?: Translate,
175
+ /** The row's `type` — for V3 the only place the name lives, since the asset carries none. */
176
+ rowType?: string
177
+ ): Inspection {
165
178
  if (!sourceJson) return inspectSource(sourceJson, releasedJson)
166
179
 
167
180
  let format: FigureFormat
@@ -174,8 +187,12 @@ export function inspectAnySource(sourceJson?: string | null, releasedJson?: stri
174
187
  if (format === 'v2') return inspectSource(sourceJson, releasedJson)
175
188
 
176
189
  let inspection
190
+ let hasParts = true
177
191
  try {
178
- inspection = inspectV3Asset(parseV3Asset(sourceJson))
192
+ const asset = parseV3Asset(sourceJson)
193
+ inspection = inspectV3Asset(asset)
194
+ const cost = v3CostOf(asset)
195
+ hasParts = cost.groups + cost.hidden > 0
179
196
  } catch (e) {
180
197
  const error = e as V3ContractError & { path?: string }
181
198
  return {
@@ -187,10 +204,25 @@ export function inspectAnySource(sourceJson?: string | null, releasedJson?: stri
187
204
  const say = sayerOf(t)
188
205
  const findings: GateFinding[] = inspection.violations.map(violation => ({
189
206
  code: violation.code.toLowerCase().replace(/_/g, '-'),
190
- ...v3Wording(violation, say),
207
+ ...v3Wording(violation, say, hasParts),
191
208
  at: violation.part,
192
209
  blocking: true
193
210
  }))
211
+ /*
212
+ V2 refuses a machine-made name at release (`type-is-placeholder`, from the source's own `type`). A V3 asset has
213
+ no `type`, so that check never ran and a new V3 figure was published as `FIGURE_<uuid>` — seen on :3300,
214
+ 2026-09-24. Publishing freezes the name (ADR-0063 decision 4) and boards call a figure by it, so the same
215
+ refusal stands here, read off the row.
216
+ */
217
+ if (rowType && isPlaceholderType(rowType)) {
218
+ findings.push({
219
+ code: 'type-is-placeholder',
220
+ message: say('figure.gate.type-is-placeholder.message', { type: rowType }, `'${rowType}' is a temporary type given when the figure was first saved.`),
221
+ why: say('figure.gate.type-is-placeholder.why', {}, 'Boards call a figure by its type, and it cannot change once the figure is published.'),
222
+ how: say('figure.gate.type-is-placeholder.how', {}, 'Set the type in the type field, then publish.'),
223
+ blocking: true
224
+ })
225
+ }
194
226
  if (inspection.coverage.method === 'sampled') {
195
227
  const states = inspection.coverage.states
196
228
  findings.push({
@@ -0,0 +1,130 @@
1
+ /*
2
+ * The inspect doors, read against a real row.
3
+ *
4
+ * `inspectAnySource` is tested on its own in figure-format.test.ts, and that is where the temporary-type refusal was
5
+ * first pinned. It still did not reach the release panel: `inspectFigure` selected only `id` and `source`, so the row's
6
+ * `type` arrived as `undefined` and the refusal never ran. The panel enabled 「발행하기」 and the release mutation —
7
+ * which loads the whole row — refused on the press (2026-09-24). A test of the function could not see that; this
8
+ * one goes through the resolvers and the database.
9
+ */
10
+ import type { DataSource } from 'typeorm'
11
+ import {
12
+ addV3Box,
13
+ applyV3Authoring,
14
+ createV3Asset,
15
+ placeholderType,
16
+ proposeV3Occupancy,
17
+ serializeV3Asset
18
+ } from '@hatiolab/figure-model'
19
+
20
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
21
+ const shell = require('@things-factory/shell')
22
+ const authBase = require('@things-factory/auth-base')
23
+
24
+ let dataSource: DataSource | null = null
25
+ let Figure: any
26
+ let FigureHistory: any
27
+ let FigureQuery: any
28
+ let domain: any
29
+
30
+ function loadCompiled(modulePath: string): any {
31
+ const { existsSync } = require('fs')
32
+ const { join } = require('path')
33
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
34
+ if (!existsSync(resolved)) {
35
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n yarn workspace @things-factory/figure-service build:server`)
36
+ }
37
+ return require(resolved)
38
+ }
39
+
40
+ /** A V3 figure that passes the gate on its own: a box standing on the floor, with its space declared. */
41
+ function sound(): string {
42
+ let asset = createV3Asset('Probe')
43
+ asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
44
+ asset = applyV3Authoring(asset, { kind: 'declare-occupancy', proposal: proposeV3Occupancy(asset, { over: 'range' }), placement: 'floor' })
45
+ return serializeV3Asset(asset)
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ Figure = loadCompiled('service/figure/figure').Figure
50
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
51
+ FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
52
+
53
+ dataSource = new TypeOrmDataSource({
54
+ type: 'sqlite',
55
+ database: ':memory:',
56
+ synchronize: true,
57
+ dropSchema: true,
58
+ namingStrategy: new shell.NamingStrategy(),
59
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
60
+ logging: false
61
+ })
62
+ await dataSource.initialize()
63
+ shell.addDataSource('default', dataSource)
64
+ shell.addDataSource('tx', dataSource)
65
+ })
66
+
67
+ beforeEach(async () => {
68
+ await dataSource!.synchronize(true)
69
+ domain = await dataSource!.getRepository(shell.Domain).save({ name: 'inspect', subdomain: 'inspect' })
70
+ })
71
+
72
+ afterAll(async () => {
73
+ if (!dataSource) return
74
+ shell.removeDataSource('default')
75
+ shell.removeDataSource('tx')
76
+ await dataSource.destroy()
77
+ dataSource = null
78
+ })
79
+
80
+ const context = () => ({ state: { domain, user: { id: 'u' } } }) as any
81
+ const save = (type: string, source = sound()) =>
82
+ dataSource!.getRepository(Figure).save({ domain, type, name: 'probe', state: 'draft', source, version: 0 })
83
+
84
+ describe('inspectFigure — the release panel', () => {
85
+ it('refuses a V3 figure still under its temporary type — the type comes from the row', async () => {
86
+ const figure = await save(placeholderType())
87
+
88
+ const inspection = await new FigureQuery().inspectFigure(figure.id, context())
89
+
90
+ expect(inspection.blocked).toBe(true)
91
+ expect(inspection.findings.map((f: any) => f.code)).toContain('type-is-placeholder')
92
+ })
93
+
94
+ it('lets the same figure through once it is named', async () => {
95
+ const figure = await save('ROLLER_CONVEYOR')
96
+
97
+ expect((await new FigureQuery().inspectFigure(figure.id, context())).blocked).toBe(false)
98
+ })
99
+ })
100
+
101
+ describe('inspectFigureDraft — the verdict while editing', () => {
102
+ it('judges the unsaved source, not the stored one, and still reads the row type', async () => {
103
+ const figure = await save(placeholderType())
104
+ let edited = createV3Asset('Probe')
105
+ edited = addV3Box(edited, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
106
+
107
+ const inspection = await new FigureQuery().inspectFigureDraft(serializeV3Asset(edited), context(), figure.id)
108
+ const codes = inspection.findings.map((f: any) => f.code)
109
+
110
+ expect(codes).toContain('occupancy-undeclared')
111
+ expect(codes).toContain('type-is-placeholder')
112
+ })
113
+
114
+ it('a figure never saved has no row type to refuse', async () => {
115
+ const inspection = await new FigureQuery().inspectFigureDraft(sound(), context())
116
+
117
+ expect(inspection.blocked).toBe(false)
118
+ })
119
+
120
+ it('declares the same permission as inspectFigure', () => {
121
+ const { readFileSync } = require('fs')
122
+ const { join } = require('path')
123
+ const source = readFileSync(join(__dirname, 'figure-query.ts'), 'utf-8')
124
+ const at = source.indexOf('async inspectFigureDraft')
125
+ const preceding = source.slice(0, at)
126
+ const declaration = preceding.slice(preceding.lastIndexOf("@Directive('@privilege("))
127
+
128
+ expect(declaration).toContain('category: "figure", privilege: "query"')
129
+ })
130
+ })
@@ -1,6 +1,7 @@
1
1
  import { In } from 'typeorm'
2
2
  import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
3
3
 
4
+ import { placeholderType } from '@hatiolab/figure-model'
4
5
  import { getRepository } from '@things-factory/shell'
5
6
 
6
7
  import { Figure } from './figure.js'
@@ -37,8 +38,14 @@ export class FigureMutation {
37
38
  if (previous.source !== prepared.source) throw Error('이 생성 요청은 이미 다른 내용으로 저장되었습니다. 저장된 모델을 다시 열어 주세요.')
38
39
  return previous
39
40
  }
41
+ /*
42
+ The temporary name comes from the format's `placeholderType()`, the one rule the release gate and the AI
43
+ tools use to recognise it. This used to build its own (`FIGURE_<32 hex>`), which `isPlaceholderType` does
44
+ not match, so a new V3 figure could be published under a machine name that then froze (ADR-0063 decision 4)
45
+ — seen on :3300, 2026-09-24. A retry is still safe: the row id is the creation id, not the name.
46
+ */
40
47
  // insert, not save: a concurrent retry must never turn into an unchecked overwrite.
41
- await repository.insert({ ...prepared, id: creationId, type: `FIGURE_${creationId.replace(/-/g, '').toUpperCase()}`,
48
+ await repository.insert({ ...prepared, id: creationId, type: placeholderType(),
42
49
  domain, creator: user, updater: user })
43
50
  return repository.findOneOrFail({ where: { id: creationId, domain: { id: domain.id } } })
44
51
  }
@@ -263,7 +270,7 @@ export class FigureMutation {
263
270
  .limit(1)
264
271
  .getOne()
265
272
 
266
- const inspection = inspectAnySource(figure.source, released?.source, context.t)
273
+ const inspection = inspectAnySource(figure.source, released?.source, context.t, figure.type)
267
274
  if (inspection.blocked) {
268
275
  const reasons = inspection.findings
269
276
  .filter(finding => finding.blocking)
@@ -114,7 +114,12 @@ export class FigureQuery {
114
114
 
115
115
  const figure = await getRepository(Figure).findOne({
116
116
  where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
117
- select: { id: true, source: true }
117
+ /*
118
+ `type` is read too. A V3 asset carries no type, so the temporary-type refusal reads the row's; with only
119
+ id and source selected it saw `undefined`, the panel enabled 「발행하기」, and the release mutation — which
120
+ loads the whole row — refused on the press (2026-09-24).
121
+ */
122
+ select: { id: true, source: true, type: true }
118
123
  })
119
124
 
120
125
  if (!figure) {
@@ -132,7 +137,41 @@ export class FigureQuery {
132
137
  .limit(1)
133
138
  .getOne()
134
139
 
135
- return inspectAnySource(figure.source, released?.source, context.t)
140
+ return inspectAnySource(figure.source, released?.source, context.t, figure.type)
141
+ }
142
+
143
+ /**
144
+ * The same judgement for a source that has not been saved — what the modeller shows **while** the author edits.
145
+ *
146
+ * Without it a V3 author saw nothing until they saved and opened the release panel. The judgement runs here, not
147
+ * in the browser, because the sentences are made from the request's language (`inspectAnySource` with `context.t`)
148
+ * and a second copy on the screen would drift from the one the release refuses with. When `id` names a saved
149
+ * figure, its row type and last release are read as `inspectFigure` does; a figure never saved has neither.
150
+ */
151
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
152
+ @Query(returns => FigureInspection, { description: 'Whether an unsaved figure source would be released, with the reasons.' })
153
+ async inspectFigureDraft(
154
+ @Arg('source') source: string,
155
+ @Ctx() context: ResolverContext,
156
+ @Arg('id', { nullable: true }) id?: string
157
+ ): Promise<FigureInspection> {
158
+ const { domain } = context.state
159
+ if (!id) return inspectAnySource(source, null, context.t)
160
+
161
+ const figure = await getRepository(Figure).findOne({
162
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
163
+ select: { id: true, type: true }
164
+ })
165
+ if (!figure) throw new Error(`Figure ${id} 를 찾을 수 없다`)
166
+
167
+ const released = await getRepository(FigureHistory)
168
+ .createQueryBuilder('history')
169
+ .where('history.originalId = :id', { id })
170
+ .orderBy('history.version', 'DESC')
171
+ .limit(1)
172
+ .getOne()
173
+
174
+ return inspectAnySource(source, released?.source, context.t, figure.type)
136
175
  }
137
176
 
138
177
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
@@ -7,6 +7,8 @@
7
7
  import { readFileSync } from 'fs'
8
8
  import { join } from 'path'
9
9
 
10
+ import { createV3Asset, parseV3Asset, serializeV3Asset } from '@hatiolab/figure-model'
11
+
10
12
  import { FigureRenameRefused, renameDraftType } from './figure-rename'
11
13
 
12
14
  const draft = (over: Record<string, unknown> = {}) => ({
@@ -25,6 +27,19 @@ describe('초안의 type 고치기', () => {
25
27
  expect(JSON.parse(result.source).type).toBe('ROBOT_ARM')
26
28
  })
27
29
 
30
+ /*
31
+ V3 자산에는 type 칸이 없고 V3 형식은 모르는 칸을 거절한다. V2 처럼 원본에 이름을 써 넣었더니
32
+ 그 도형을 다시 열 수 없었다(`parseV3Asset` — "unknown type"). V3 는 행만 바뀐다.
33
+ */
34
+ it('★ V3 도형은 행만 바뀌고, 원본은 그대로 다시 열린다', () => {
35
+ const source = serializeV3Asset(createV3Asset('새 Figure'))
36
+ const result = renameDraftType({ type: 'FIGURE_ABCDEF12', source }, 'ROLLER_CONVEYOR', never)
37
+
38
+ expect(result.type).toBe('ROLLER_CONVEYOR')
39
+ expect(result.source).toBe(source)
40
+ expect(() => parseV3Asset(result.source)).not.toThrow()
41
+ })
42
+
28
43
  it('정본의 다른 값은 건드리지 않는다', () => {
29
44
  const before = JSON.parse(draft().source)
30
45
  const after = JSON.parse(renameDraftType(draft(), 'ROBOT_ARM', never).source)
@@ -91,6 +106,18 @@ describe('문이 이 규칙을 지난다', () => {
91
106
  expect(/renamed !== undefined \? \{ type: renamed \}/.test(mutation)).toBe(true)
92
107
  })
93
108
 
109
+ /*
110
+ V3 저장이 임시 이름을 스스로 지었다(`FIGURE_<32 hex>`). 형식의 `isPlaceholderType` 이 그것을 못 알아봐서
111
+ 발행 판정이 기계 이름을 통과시켰다. 짓는 쪽과 알아보는 쪽이 한 규칙이어야 한다.
112
+ */
113
+ it('새 V3 도형의 임시 이름은 형식의 규칙으로 짓는다 — 판정이 알아보는 그 규칙', () => {
114
+ const mutation = readFileSync(join(__dirname, 'figure-mutation.ts'), 'utf8')
115
+ const save = mutation.slice(mutation.indexOf('async saveFigureV3'), mutation.indexOf('async createFigure'))
116
+
117
+ expect(save).toContain('type: placeholderType()')
118
+ expect(save).not.toMatch(/type: `FIGURE_/)
119
+ })
120
+
94
121
  it('문이 type 을 받는 칸을 갖는다', () => {
95
122
  const type = source('figure-type.ts')
96
123
 
@@ -19,6 +19,8 @@
19
19
  * 그래야 규칙을 DB 없이 시험한다. 문(`figure-mutation.ts`)이 그 셋을 읽어 넘긴다.
20
20
  */
21
21
 
22
+ import { V3_ASSET_VERSION } from './figure-format.js'
23
+
22
24
  /** 이름을 바꿀 때 알아야 하는 사실. 부르는 쪽이 저장소에서 읽어 온다. */
23
25
  export interface RenameFacts {
24
26
  /** 발행 판 번호. 0 이면 한 번도 발행되지 않았다. */
@@ -67,9 +69,16 @@ export function renameDraftType(
67
69
  throw new FigureRenameRefused('type-taken', `type 「${next}」 는 이 도메인에서 이미 쓰고 있다`)
68
70
  }
69
71
 
70
- const parsed = JSON.parse(current.source) as { type?: string }
71
- parsed.type = next
72
+ const parsed = JSON.parse(current.source) as { type?: string; version?: unknown }
72
73
 
74
+ /*
75
+ A V3 asset carries no `type`: the name a board uses is the row's alone, and the V3 schema refuses a field it
76
+ does not know. Writing it in, as V2 needs, left a V3 figure that could no longer be opened — `parseV3Asset`
77
+ answered "unknown type" (checked 2026-09-24). So only the row changes for V3.
78
+ */
79
+ if (parsed.version === V3_ASSET_VERSION) return { type: next, source: current.source }
80
+
81
+ parsed.type = next
73
82
  return { type: next, source: JSON.stringify(parsed) }
74
83
  }
75
84
 
@@ -1,9 +1,9 @@
1
1
  /*
2
- * figure 도구가 지나는 문 — ADR-0072 결정 2, ADR-0071 결정 2 보탬(2026-09-18).
2
+ * The doors figure's tools pass — ADR-0072 decision 2, ADR-0071 decision 2 addendum (2026-09-18).
3
3
  *
4
- * 선언만 해 두고 아무도 읽지 않으면 그 선언은 다음 사람이 지우거나 바꿔도 아무 일이 없다. 이제는
5
- * 읽는 쪽이 생겼다 — 관문이 이 목록으로 도구를 내보낼지 정한다. 그래서 여기서 묻는 것이 둘이다:
6
- * 도구가 문을 적었는가, 그리고 **그 문이 무엇인가를 지키는가.**
4
+ * A declaration nobody reads can be deleted or changed by the next person with no effect. Now it has a
5
+ * reader — the gate decides from this list whether to offer the tool. So this asks two things: did the
6
+ * tool name its doors, and **does each door guard anything.**
7
7
  */
8
8
  import { readdirSync, readFileSync } from 'fs'
9
9
  import { join } from 'path'
@@ -33,15 +33,15 @@ describe('figure tools — declared doors', () => {
33
33
  })
34
34
 
35
35
  /*
36
- * 도구가 가리키는 문이 무엇인가를 지키는가 — 관문이 딛고 선 사실.
36
+ * Does each door the tools point at guard anything — the fact the gate stands on.
37
37
  *
38
- * 2026-09-18 까지 도구 명세가 권한을 베껴 적었고 이 자리가 그 사본을 문과 견줬다. 사본은 없어졌다
39
- * (ADR-0071 결정 2 보탬) — 관문이 AUTH 의 필드 표에서 문의 선언을 직접 읽는다. 그래서 견줄 두 값도
40
- * 없다.
38
+ * Until 2026-09-18 the tool specs copied a privilege and this spot compared the copy with the door.
39
+ * The copy is gone (ADR-0071 decision 2 addendum) — the gate reads the door's declaration from AUTH's
40
+ * field table directly, so there are no longer two values to compare.
41
41
  *
42
- * 대신 관문이 **딛고 선 사실**이 남았다: 이 네 도구가 가리키는 문에 지시자가 붙어 있다는 것. 누가
43
- * `Query.figures` 에서 `@privilege` 를 떼면 그 문은 테넌트 안에서 열리고, 도구도 같이 열린다 —
44
- * 그 자리가 이제 아무 데서도 안 드러난다. 여기서 문다.
42
+ * What remains is **the fact the gate stands on**: the doors these four tools point at carry a
43
+ * directive. Strip `@privilege` from `Query.figures` and that door opens within the tenant, and the
44
+ * tool opens with it — and nothing else would show it. This catches it.
45
45
  */
46
46
  describe('figure tools — the doors they reach are guarded', () => {
47
47
  beforeEach(() => {
@@ -104,13 +104,13 @@ describe('figure tools — the doors they reach are guarded', () => {
104
104
  return found
105
105
  }
106
106
 
107
- it('★ 도구가 가리키는 문마다 그 문을 지키는 지시자가 있다', () => {
107
+ it('★ every door a tool points at carries the directive that guards it', () => {
108
108
  const doors = doorPrivileges()
109
109
 
110
110
  const unguarded = getAllToolSpecs()
111
111
  .flatMap(spec =>
112
112
  ((spec as any).doors ?? []).map((door: string) =>
113
- doors.has(door) ? undefined : `${spec.name} → ${door}: 그런 문이 없거나 그 문이 권한을 선언하지 않는다`
113
+ doors.has(door) ? undefined : `${spec.name} → ${door}: no such door, or the door declares no privilege`
114
114
  )
115
115
  )
116
116
  .filter(Boolean)
@@ -18,11 +18,11 @@
18
18
  * `ai-assistant:mutation` 이 지킨다 — 서로 다른 질문이라 서로 다른 권한이 지킨다. figure 가 board
19
19
  * 의 권한을 빌리지 않는다.
20
20
  *
21
- * ── 권한은 적지 않는다. 문에 적힌 것을 관문이 읽는다 ────────────────────────
22
- * 2026-09-18 까지 도구마다 `privilege: { category: 'figure', ... }` 를 적어 두었다. 문의 지시자와
23
- * 같은 값이라 그때는 맞았고, 문이 선언을 바꾸는 날 어긋난다 — 어긋나면 느슨한 쪽이 이긴다. 이제
24
- * 관문이 `doors` 에 적힌 문의 선언을 AUTH 의 표에서 읽어 묻는다(ADR-0071 결정 2 보탬). 여기 남길
25
- * 것은 **닿는 문**뿐이다.
21
+ * ── No privilege here. The gate reads what the door declares ─────────────────────
22
+ * Until 2026-09-18 each tool carried `privilege: { category: 'figure', ... }`. It matched the door's
23
+ * directive then, and would drift the day the door changed its declaration — and the looser copy wins.
24
+ * Now the gate reads the declaration of each door in `doors` from AUTH's table (ADR-0071 decision 2,
25
+ * addendum). What stays here is **the doors it reaches**.
26
26
  *
27
27
  * ── 규율을 도구와 함께 보낸다 ───────────────────────────────────────────────
28
28
  * 도구 목록만으로는 「언제 반드시 불러야 하는지」를 모른다. twin 이 그 공백에서 사고를 냈다 —
@@ -2,6 +2,7 @@
2
2
  "error.figure-release-does-not-compile": "the released source of {type} (version {version}) does not compile. open it in the modeller, fix it and release it again before it draws on a board.",
3
3
  "figure.gate.occupancy-empty-axis.message": "The declared space is empty on {axis}.",
4
4
  "figure.gate.occupancy-undeclared.how": "Use \"fit to parts\" to declare the space from the parts as they are now.",
5
+ "figure.gate.occupancy-undeclared.how-no-parts": "Add a shape first, then use \"fit to parts\" to declare the space from it.",
5
6
  "figure.gate.occupancy-undeclared.message": "This figure has not declared the space it takes up.",
6
7
  "figure.gate.occupancy-undeclared.why": "Without a declared space the scale a figure is placed at cannot be set.",
7
8
  "figure.gate.part-outside-occupancy.how": "Use \"fit to parts\" to declare the space from the parts again, or move '{part}' {mm} mm inward on {axis}.",
@@ -16,6 +17,9 @@
16
17
  "figure.gate.state-range-sampled.how": "Re-author the motion that could not be proven, and the whole range can be proven.",
17
18
  "figure.gate.state-range-sampled.message": "The whole range of motion could not be proven, so {states} poses were checked.",
18
19
  "figure.gate.state-range-sampled.why": "Between the poses that were checked, a part may still go past the declared space.",
20
+ "figure.gate.type-is-placeholder.how": "Set the type in the type field, then publish.",
21
+ "figure.gate.type-is-placeholder.message": "'{type}' is a temporary type given when the figure was first saved.",
22
+ "figure.gate.type-is-placeholder.why": "Boards call a figure by its type, and it cannot change once the figure is published.",
19
23
  "figure.gate.v3-released-not-compared.message": "Not compared with the last release. V3 figures have no comparison between releases yet.",
20
24
  "figure.gate.v3-released-not-compared.why": "Whether this release breaks the instances already placed has not been checked."
21
25
  }
@@ -2,6 +2,7 @@
2
2
  "error.figure-release-does-not-compile": "{type} 의 발행판({version})이 지금 컴파일되지 않습니다. 저작 화면에서 열어 고친 뒤 다시 발행해야 보드에 나옵니다.",
3
3
  "figure.gate.occupancy-empty-axis.message": "공간 선언의 {axis} 가 비어 있습니다.",
4
4
  "figure.gate.occupancy-undeclared.how": "「부품에 맞추기」를 누르면 지금 부품으로 공간을 선언합니다.",
5
+ "figure.gate.occupancy-undeclared.how-no-parts": "먼저 도형을 추가한 뒤, 「부품에 맞추기」로 공간을 선언하세요.",
5
6
  "figure.gate.occupancy-undeclared.message": "이 도형은 차지하는 공간을 아직 선언하지 않았습니다.",
6
7
  "figure.gate.occupancy-undeclared.why": "공간 선언이 없으면 도면에 놓일 때의 배율을 정할 수 없습니다.",
7
8
  "figure.gate.part-outside-occupancy.how": "「부품에 맞추기」로 공간을 부품에서 다시 선언하거나, '{part}' 을 {axis} 로 {mm}mm 안으로 옮기세요.",
@@ -16,6 +17,9 @@
16
17
  "figure.gate.state-range-sampled.how": "증명하지 못한 움직임을 다시 저작하면 범위 전체를 증명할 수 있습니다.",
17
18
  "figure.gate.state-range-sampled.message": "움직이는 범위 전체를 증명하지 못해 자세 {states}개만 검사했습니다.",
18
19
  "figure.gate.state-range-sampled.why": "검사한 자세 사이에서 부품이 공간 선언을 넘을 수 있습니다.",
20
+ "figure.gate.type-is-placeholder.how": "타입 칸에서 타입을 정한 뒤 발행하세요.",
21
+ "figure.gate.type-is-placeholder.message": "'{type}' 은 처음 저장할 때 붙은 임시 타입입니다.",
22
+ "figure.gate.type-is-placeholder.why": "보드는 이 타입으로 도형을 부르고, 발행한 뒤에는 바꿀 수 없습니다.",
19
23
  "figure.gate.v3-released-not-compared.message": "직전 발행판과 견주지 못했습니다. V3 도형은 발행 사이의 비교가 아직 없습니다.",
20
24
  "figure.gate.v3-released-not-compared.why": "이 발행이 이미 놓인 인스턴스를 깨는지는 확인되지 않았습니다."
21
25
  }