@cardenelabs/dragon 0.7.0 → 0.9.0

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.
@@ -18,8 +18,29 @@
18
18
  * YAML `animation: [step: "..."]` ⇔ JSON `{animation: [{step: "...", duration: 1.4, focus: [...]}]}`
19
19
  */
20
20
 
21
+ import {
22
+ DRAW_WORDS,
23
+ NODE_KIND_VALID,
24
+ PRESET_TYPES,
25
+ STYLE_VALID,
26
+ resolveNodeKind,
27
+ resolveTone,
28
+ splitColorValue,
29
+ 書ける色名,
30
+ } from "./v05/parser";
31
+ import type { CompileToCdlOpts } from "./compile";
21
32
  import type { CdlDiagram, NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
22
- import type { DslDocument, DslActor, DslStep, DslAnimate, DslPhase, PresetType, LayoutMode, LayoutPos } from "./types";
33
+ import type {
34
+ DslDocument,
35
+ DslActor,
36
+ DslStep,
37
+ DslAnimate,
38
+ DslPhase,
39
+ DslState,
40
+ PresetType,
41
+ LayoutPos,
42
+ } from "./types";
43
+ import { checkValueExpression, isValueName, valueNameIssue } from "./value-syntax";
23
44
  import { compileToCdl } from "./compile";
24
45
 
25
46
  /**
@@ -30,10 +51,44 @@ export interface DragonJson {
30
51
  title: string;
31
52
  /** preset type (必須): sequence / flow / swimlane / er / state / topology / solidity / gantt / class / pie / c4 / mind */
32
53
  type: PresetType;
54
+ /**
55
+ * 図表の箱の上に出す小見出し (optional)。 記法の最上位 `eyebrow:` と同じ (#1247)。
56
+ *
57
+ * 効くのは図全体を 1 箱にする図種 (`pie` / `bar` / `line` / `funnel` / `tree` / `journey` /
58
+ * `quadrant` / `mind` / `gantt`) だけ。 箱ごとに分かれる図種では相手が決まらないため、
59
+ * 組み立て側が知らせを出す。 そちらは `actors[].eyebrow` に書く。
60
+ */
61
+ eyebrow?: string;
62
+ /**
63
+ * 2 軸で仕分ける図 (`type: quadrant`) の軸の名前 (#1294)。 記法の `axes:` と同じ。
64
+ *
65
+ * 書かないと「小さい / 大きい」 のままになり、何を判断する図か読めない。 区画の名前
66
+ * (`右上` 等) は軸の名前から決まる。
67
+ *
68
+ * 他の図種には軸が無いため、書かれていたら組み立て側が知らせる。
69
+ */
70
+ axes?: JsonAxes;
33
71
  /** 登場人物 (必須): 文字列 or { name, kind, ... } object */
34
72
  actors: (string | JsonActor)[];
35
73
  /** flow step 配列 (必須): { from, to, label, ... } */
36
74
  flow: JsonStep[];
75
+ /**
76
+ * 状態の初期値 (optional)。 記法の `states:` と同じ (#1181)。
77
+ *
78
+ * `{名前}` を箱の文字に置くと、ここに書いた値が描画側で置き換わる。 名前は英数字と `_`
79
+ * だけ (描画側が置き換える時に見る範囲と揃える)。
80
+ *
81
+ * ここに書けるのは初期値まで。 段で動かすのは `animation[].tween` / `animation[].set`
82
+ * (`#1186` で追加、記法の `tween:` / `set:` と同じ)。
83
+ */
84
+ states?: Record<string, number | string>;
85
+ /**
86
+ * 他の値から自動で決まる値 (optional)。 記法の `values:` と同じ (#1181)。
87
+ *
88
+ * 式には四則 (`+ - * /`) と括弧、比較 (`> >= < <= == !=`)、`min` / `max` が書ける。
89
+ * 他の値は `{名前}` で読む。 解くのは描画側で、毎 frame 参照から順に決まる。
90
+ */
91
+ values?: Record<string, string>;
37
92
  /** animation phase 配列 (optional) */
38
93
  animation?: JsonPhase[];
39
94
  /** viewport (optional): 全体 canvas size / gap */
@@ -47,28 +102,29 @@ export interface DragonJson {
47
102
  labelMargin?: number;
48
103
  };
49
104
  /** lanes (optional): topology / swimlane preset で使う lane 宣言 */
50
- lanes?: Record<string, {
51
- x?: number;
52
- width?: number;
53
- label?: string;
54
- contain?: boolean;
55
- lifeline?: boolean;
56
- /**
57
- * canvas pivot (CAR-1693 Phase 1) DSL 表面 `pos: {x, y}` = auto layout offset。 未指定は
58
- * backward compat、 set 済は Phase 2 の applyPosOffset で lane 位置を shift する。
59
- */
60
- pos?: LayoutPos;
61
- }>;
105
+ lanes?: Record<
106
+ string,
107
+ {
108
+ x?: number;
109
+ width?: number;
110
+ label?: string;
111
+ contain?: boolean;
112
+ lifeline?: boolean;
113
+ /**
114
+ * canvas pivot (CAR-1693 Phase 1) DSL 表面 `pos: {x, y}` = auto layout offset。 未指定は
115
+ * backward compat、 set 済は Phase 2 の applyPosOffset で lane 位置を shift する。
116
+ */
117
+ pos?: LayoutPos;
118
+ }
119
+ >;
62
120
  /** groups (optional): topology preset で使う group 宣言 */
63
- groups?: Record<string, {
64
- label?: string;
65
- lanes: string[];
66
- }>;
67
- /**
68
- * canvas pivot (CAR-1693 Phase 1) diagram-level layout mode。 "auto" (default) は catalog 100+
69
- * backward compat、 "manual" は Phase 4 で drag → pos: 保存の完全 manual mode として使う予定。
70
- */
71
- layout?: LayoutMode;
121
+ groups?: Record<
122
+ string,
123
+ {
124
+ label?: string;
125
+ lanes: string[];
126
+ }
127
+ >;
72
128
  }
73
129
 
74
130
  export interface JsonActor {
@@ -99,8 +155,80 @@ export interface JsonActor {
99
155
  * LLM JSON DSL では nested 明示 = `{ "state": { "v": 50 } }` が natural、 human 側の
100
156
  * inline 拡散 pattern (`- arc1: { kind: arc-gauge, v: 50 }`) とは記述形式が分岐する
101
157
  * (spec § 2.3 分岐設計、 human = YAML 手書き最適 / LLM = JSON structured 最適)。
158
+ *
159
+ * 見本でない箱に書くと誤りとして返す (#1294)。 記法側は読めない項目名として行番号付きで
160
+ * 知らせるため、黙って捨てると入口によって扱いが変わる。
102
161
  */
103
162
  state?: Record<string, number | string | boolean>;
163
+ /**
164
+ * 箱の色 (#1294)。 記法の `tone:` と同じ。
165
+ *
166
+ * 色の名前と別名 (`成功` / `success` 等) を受ける。 見本 (parts) では色ではなく状態の
167
+ * 上書きとして意味を持つため、記法と同じく見本の箱には効かない。
168
+ */
169
+ tone?: Tone | (string & {});
170
+ /**
171
+ * 色番号または色の名前 (#1294)。 記法の `色:` / `color:` と同じ。
172
+ *
173
+ * `#` で始まる値は色番号として `colorHex` に入り、見本の絵の色を変える。 それ以外は
174
+ * 色の名前として読む (記法の `splitColorValue` と同じ振り分け)。
175
+ */
176
+ color?: string;
177
+ /**
178
+ * 工程の並び (`type: gantt`) で、その工程の担当 (#1294)。 記法の `owner:` と同じ。
179
+ */
180
+ owner?: string;
181
+ /**
182
+ * 工程の並び (`type: gantt`) で、その工程が終わる時期 (#1294)。 記法の `end:` と同じ。
183
+ *
184
+ * 値は他の工程が書いた時期のどれかに一致させる。 一致しない値は始まりと同じ位置に落ちる
185
+ * (記法側と同じ扱い)。
186
+ */
187
+ end?: string;
188
+ /**
189
+ * 体験の道筋 (`type: journey`) で、その段階が起きる場所 (#1294)。 記法の `touchpoint:` と同じ。
190
+ */
191
+ touchpoint?: string;
192
+ /**
193
+ * 体験の道筋 (`type: journey`) で、その段階の改善の余地 (#1294)。 記法の `opportunity:` と同じ。
194
+ */
195
+ opportunity?: string;
196
+ /**
197
+ * 箱を置く絶対座標と大きさ (#1294)。 記法の `posX:` / `posY:` / `posW:` / `posH:` と同じ。
198
+ *
199
+ * `pos` (ずらし幅) とは別物。 こちらは auto layout を使わずに位置そのものを決める。
200
+ */
201
+ posX?: number;
202
+ posY?: number;
203
+ posW?: number;
204
+ posH?: number;
205
+ /**
206
+ * 箱の中の要素ごとに位置と大きさを固定する (#1294)。 記法の `nodes:` と同じ。
207
+ *
208
+ * key は箱が作る要素の名前 (`header` / `footer` / `spacer` / `s0` 等)。
209
+ */
210
+ nodes?: Record<string, JsonActorNodeOverride>;
211
+ /**
212
+ * 見本を何倍で描くか (#1294)。 記法の `scale:` / `倍率:` と同じ。
213
+ *
214
+ * 見本 (parts) にしか効かない。 見本でない箱に書くと誤りとして返す (記法側も同じく
215
+ * 読めない項目名として知らせる)。
216
+ */
217
+ scale?: number;
218
+ }
219
+
220
+ /** 2 軸で仕分ける図の軸の名前 (#1294)。 記法の `axes:` と同じ形 */
221
+ export interface JsonAxes {
222
+ x?: { left?: string; right?: string };
223
+ y?: { bottom?: string; top?: string };
224
+ }
225
+
226
+ /** 箱の中の要素 1 つ分の位置と大きさ (#1294)。 記法の `nodes: { header: { ... } }` と同じ */
227
+ export interface JsonActorNodeOverride {
228
+ posX?: number;
229
+ posY?: number;
230
+ posW?: number;
231
+ posH?: number;
104
232
  }
105
233
 
106
234
  export interface JsonStep {
@@ -108,12 +236,19 @@ export interface JsonStep {
108
236
  to: string;
109
237
  label: string;
110
238
  sub?: string;
111
- tone?: Tone;
239
+ /**
240
+ * 矢印の色 (#1304)。 記法の `(成功)` と同じく別名 (`成功` / `neutral` 等) も受ける。
241
+ *
242
+ * `string & {}` は箱の `tone` と同じ idiom = 正規の色名を補完に出しつつ別名も通す。
243
+ */
244
+ tone?: Tone | (string & {});
112
245
  style?: EdgeStyle;
113
246
  guard?: string;
114
247
  cardinality?: string;
115
248
  labelOffsetX?: number;
116
249
  labelOffsetY?: number;
250
+ /** true で説明文を矢印の線の上に重ねる。 分岐図の条件ラベル用。 */
251
+ overlay?: boolean;
117
252
  /**
118
253
  * canvas pivot (CAR-1693 Phase 1) DSL 表面 `pos: {x, y}` = edge label offset。 未指定は
119
254
  * backward compat、 set 済は Phase 2 の applyPosOffset で edge label 位置を shift する。
@@ -132,6 +267,26 @@ export interface JsonPhase {
132
267
  body?: string;
133
268
  /** badge label */
134
269
  badge?: string;
270
+ /**
271
+ * その段で左の起点から描くもの (#1312)。 記法の `draw: line` と同じ。
272
+ *
273
+ * 受ける語は `line` だけ (`DRAW_WORDS`)。 折れ線の図で、その段の間に線が左端から
274
+ * 右へ伸びる。
275
+ */
276
+ draw?: string;
277
+ /**
278
+ * 段の中で値を動かす (#1186)。 記法の `tween: name 100 -> 90` と同じ。
279
+ *
280
+ * 足すまで JSON の入口は `states:` で初期値を書けても **動かす手段が無かった** ため、
281
+ * 同じ図を記法で書くと動き JSON で書くと静止する状態だった (#1181 で状態を足した時の残り)。
282
+ */
283
+ tween?: Record<string, readonly [number, number]>;
284
+ /**
285
+ * 段の切替で値を差し替える (#1186)。 記法の `set: name value` と同じ。
286
+ *
287
+ * `tween` が段の中を補間するのに対し、こちらは段の切替時に 1 度だけ変える。
288
+ */
289
+ set?: Record<string, number | string>;
135
290
  }
136
291
 
137
292
  /**
@@ -144,31 +299,541 @@ export interface JsonDslError {
144
299
  }
145
300
 
146
301
  /**
147
- * CAR-1657 = 既存 NodeKind list (v05/parser.ts の NODE_KIND_VALID と揃える必要あり)。
148
- * 未知 kind 値は parts identifier 候補として partId に格納する経路の判定基準。
149
- * v05 parser との drift 防止のため、 別 PR で共通化検討 (`packages/dragon/src/kinds.ts` etc)。
302
+ * 受け付ける種類 (`kind`)。 **記法側と同じ集合を使う** (`v05/parser.ts``NODE_KIND_VALID`)。
303
+ *
304
+ * ここに無い値は見本 (parts) の名前とみなし、`partId` へ退避して箱を `actor` に倒す
305
+ * (CAR-1657 の unified syntax)。 つまりこの集合が小さいほど、**書いた種類が黙って消える**。
306
+ *
307
+ * 以前はここに一覧を写していたため、記法に種類が増えても JSON 経路が古い一覧のまま
308
+ * 見本の名前として扱った (実測 = 記法が知る 108 種のうち 77 種が該当、`shape-*` は全滅)。
309
+ * 見本帳に無い名前は `console.warn` にしか残らず `onNotice` を呼ばないため、
310
+ * 画面には何も出ないまま `actor` の箱が描かれていた (#1293)。
311
+ *
312
+ * 型が 2 箇所に散らばると、必ずどちらかが古くなる。 `VALID_PRESETS` (図種) と同じ扱いにする。
313
+ */
314
+ const VALID_KIND_SET: ReadonlySet<string> = NODE_KIND_VALID;
315
+
316
+ /**
317
+ * 受け付ける図種。 **記法側と同じ集合を使う** (`v05/parser.ts` の `PRESET_TYPES`)。
318
+ *
319
+ * 以前はここに一覧を写していたため、 記法に型を足しても JSON 経路が古い一覧のまま弾いた
320
+ * (`bar` / `line` で実際に起きた)。 型が 3 箇所に散らばると、 必ずどれかが古くなる。
321
+ */
322
+ const VALID_PRESETS: readonly PresetType[] = [...PRESET_TYPES];
323
+
324
+ /**
325
+ * 受け付ける項目の一覧 (#1295)。 **知らない項目を誤りにする判定と、公開 schema との
326
+ * 突き合わせが、どちらもここを見る**。
327
+ *
328
+ * 一覧を型 / 検査 / schema の 3 箇所に手で置くと必ずどれかが古くなる。 型は TypeScript が
329
+ * 見る宣言で実行時には残らないため、実行時の判定と schema はこの表を出どころにする。
330
+ *
331
+ * 公開している JSON Schema は元から全階層で `additionalProperties: false` を宣言していた。
332
+ * 知らない項目を弾くのは新しい方針ではなく、parser が自分の契約に追いついていなかった。
333
+ *
334
+ * `states` / `values` / `tween` / `set` / `lanes` / `groups` の **鍵は利用者が決める**
335
+ * (状態の名前 / 縦列の id)。 表が縛るのはその中の値の形で、鍵そのものではない。
336
+ */
337
+ export const ACCEPTED_KEYS = {
338
+ root: [
339
+ "title",
340
+ "type",
341
+ "eyebrow",
342
+ "axes",
343
+ "actors",
344
+ "flow",
345
+ "states",
346
+ "values",
347
+ "animation",
348
+ "viewport",
349
+ "lanes",
350
+ "groups",
351
+ ],
352
+ actor: [
353
+ "name",
354
+ "kind",
355
+ "subtitle",
356
+ "eyebrow",
357
+ "value",
358
+ "rows",
359
+ "lane",
360
+ "stack",
361
+ "initial",
362
+ "final",
363
+ "tone",
364
+ "color",
365
+ "owner",
366
+ "end",
367
+ "touchpoint",
368
+ "opportunity",
369
+ "posX",
370
+ "posY",
371
+ "posW",
372
+ "posH",
373
+ "nodes",
374
+ "scale",
375
+ "state",
376
+ "pos",
377
+ ],
378
+ step: [
379
+ "from",
380
+ "to",
381
+ "label",
382
+ "sub",
383
+ "tone",
384
+ "style",
385
+ "guard",
386
+ "cardinality",
387
+ "labelOffsetX",
388
+ "labelOffsetY",
389
+ "overlay",
390
+ "pos",
391
+ ],
392
+ phase: ["step", "duration", "focus", "body", "badge", "tween", "set", "draw"],
393
+ viewport: ["width", "height", "scale", "laneWidth", "gap", "laneGap", "nodeGap", "labelMargin"],
394
+ lane: ["x", "width", "label", "contain", "lifeline", "pos"],
395
+ group: ["label", "lanes"],
396
+ actorNode: ["posX", "posY", "posW", "posH"],
397
+ axes: ["x", "y"],
398
+ axesX: ["left", "right"],
399
+ axesY: ["bottom", "top"],
400
+ layoutPos: ["x", "y"],
401
+ } as const satisfies Record<string, readonly string[]>;
402
+
403
+ /** 受ける項目を持つ階層の名前 */
404
+ export type 階層 = keyof typeof ACCEPTED_KEYS;
405
+
406
+ /**
407
+ * 欄ごとの値の型 (#1304)。 **`ACCEPTED_KEYS` と同じ欄を必ず持つ**。
408
+ *
409
+ * 項目名の側は `#1295` で閉じたが、値の側は一部の欄にしか検査が無かった。 実測すると
410
+ * 20 欄が型違いの値をそのまま通し、`type: sequence` では 17 欄の値が図まで届いていた
411
+ * (`labelOffsetX: "q"` が矢印の中に `"q"` のまま入る、`tone: "bogus"` がそのまま色として載る)。
412
+ *
413
+ * 表を `ACCEPTED_KEYS` の隣に置き、下の `satisfies` で **欄が 1 つでも欠けたら型検査が落ちる**
414
+ * ようにする。 欄を足した時に「名前は受けるが値は見ない」 状態が作れない。
415
+ *
416
+ * ## 誰がどの欄を見るか
417
+ *
418
+ * | 型 | 見る場所 |
419
+ * |---|---|
420
+ * | 値そのものの型 (文字列 / 数 / 真偽 / 色 / 線種 等) | 本 file の `表で検査` |
421
+ * | `object` / `並び` | 欄ごとの専用の検査 (`validateViewport` 等) |
422
+ *
423
+ * 分けるのは、中身の形が欄ごとに違うから。 外側の形だけを表で見ても中身は見られないので、
424
+ * 専用の検査に任せて二重に誤りを出さない。 **専用の検査が抜けても表からは分からない** ため、
425
+ * 検査 (`test/json-value-types.test.ts`) が全欄に型違いの値を入れて誤りが返ることを確かめる。
426
+ */
427
+ export type 欄の型 =
428
+ | "必須の非空文字列"
429
+ | "必須の文字列"
430
+ | "非空の文字列"
431
+ | "文字列"
432
+ | "文字列の並び"
433
+ | "数"
434
+ | "必須の数"
435
+ | "真偽"
436
+ | "色"
437
+ | "線種"
438
+ | "色か色番号"
439
+ | "描くもの"
440
+ | "必須の図種"
441
+ | "object"
442
+ | "並び"
443
+ | "必須の並び"
444
+ | "必須の非空の並び";
445
+
446
+ export const 欄の型表 = {
447
+ root: {
448
+ title: "必須の非空文字列",
449
+ type: "必須の図種",
450
+ eyebrow: "文字列",
451
+ axes: "object",
452
+ actors: "必須の非空の並び",
453
+ flow: "必須の並び",
454
+ states: "object",
455
+ values: "object",
456
+ animation: "並び",
457
+ viewport: "object",
458
+ lanes: "object",
459
+ groups: "object",
460
+ },
461
+ actor: {
462
+ name: "必須の非空文字列",
463
+ kind: "非空の文字列",
464
+ subtitle: "文字列",
465
+ eyebrow: "文字列",
466
+ value: "文字列",
467
+ rows: "文字列の並び",
468
+ lane: "文字列",
469
+ stack: "数",
470
+ initial: "真偽",
471
+ final: "真偽",
472
+ tone: "色",
473
+ color: "色か色番号",
474
+ owner: "文字列",
475
+ end: "文字列",
476
+ touchpoint: "文字列",
477
+ opportunity: "文字列",
478
+ posX: "数",
479
+ posY: "数",
480
+ posW: "数",
481
+ posH: "数",
482
+ nodes: "object",
483
+ scale: "数",
484
+ state: "object",
485
+ pos: "object",
486
+ },
487
+ step: {
488
+ from: "必須の文字列",
489
+ to: "必須の文字列",
490
+ label: "必須の文字列",
491
+ sub: "文字列",
492
+ tone: "色",
493
+ style: "線種",
494
+ guard: "文字列",
495
+ cardinality: "文字列",
496
+ labelOffsetX: "数",
497
+ labelOffsetY: "数",
498
+ overlay: "真偽",
499
+ pos: "object",
500
+ },
501
+ phase: {
502
+ step: "必須の非空文字列",
503
+ duration: "数",
504
+ focus: "文字列の並び",
505
+ body: "文字列",
506
+ badge: "文字列",
507
+ tween: "object",
508
+ set: "object",
509
+ draw: "描くもの",
510
+ },
511
+ viewport: {
512
+ width: "数",
513
+ height: "数",
514
+ scale: "数",
515
+ laneWidth: "数",
516
+ gap: "数",
517
+ laneGap: "数",
518
+ nodeGap: "数",
519
+ labelMargin: "数",
520
+ },
521
+ lane: {
522
+ x: "数",
523
+ width: "数",
524
+ label: "文字列",
525
+ contain: "真偽",
526
+ lifeline: "真偽",
527
+ pos: "object",
528
+ },
529
+ group: { label: "文字列", lanes: "文字列の並び" },
530
+ actorNode: { posX: "数", posY: "数", posW: "数", posH: "数" },
531
+ axes: { x: "object", y: "object" },
532
+ axesX: { left: "文字列", right: "文字列" },
533
+ axesY: { bottom: "文字列", top: "文字列" },
534
+ // 位置は書けば x と y の両方が要る。 片方だけでは寄せ幅が決まらない
535
+ layoutPos: { x: "必須の数", y: "必須の数" },
536
+ } as const satisfies {
537
+ [層 in 階層]: { [欄 in (typeof ACCEPTED_KEYS)[層][number]]: 欄の型 };
538
+ };
539
+
540
+ /**
541
+ * 色番号の形 (#1304)。 公開 schema の `pattern` と同じ形を実装側でも 1 箇所に持つ。
542
+ *
543
+ * 受けるのは 3 / 4 / 6 / 8 桁 (`#rgb` / `#rgba` / `#rrggbb` / `#rrggbbaa`)。 5 桁や 7 桁は
544
+ * CSS の色として成立しないため通さない。
545
+ */
546
+ const 色番号の形 = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
547
+
548
+ /**
549
+ * JSON で色名として受ける値か (#1304、 review Round 2 の指摘)。
550
+ *
551
+ * **記法の `resolveTone` は通さない**。 あちらは前後の空白と引用符を落として小文字に寄せるが、
552
+ * それは記法の本文から語を切り出すために要る処理で、JSON には要らない。 JSON の値は既に
553
+ * 切り出された文字列なので、`" success "` や `"\"success\""` は書き手の意図ではなく
554
+ * 記法の癖が漏れた形になる。
555
+ *
556
+ * 公開 schema は色名を `enum` で宣言する。 `enum` は完全一致なので、**parser 側も完全一致に
557
+ * 揃える**。 揃えないと「schema が拒む値を parser が受ける」 ずれが残り、本 file が閉じようと
558
+ * している宣言と実装の食い違いを別の形で作ることになる。
559
+ *
560
+ * 揃え方は 2 通りあった。 schema を parser に合わせて広げる案は、大文字小文字と空白と引用符を
561
+ * `pattern` で書くことになり、**色名の一覧が `enum` と `pattern` の 2 か所に写る**。 色が増えた
562
+ * 時に片方だけ直る形を作るため採らなかった。 同じ理由で、この判定は `tone` (箱と矢印) と
563
+ * `color` の 3 欄すべてが共有する。
564
+ */
565
+ function JSONの色名か(v: string): boolean {
566
+ return 書ける色名().includes(v);
567
+ }
568
+
569
+ /**
570
+ * 見本 (parts) にしか効かない欄 (#1294)。
571
+ *
572
+ * 普通の箱に書くと `jsonToDoc` が落とすため、書いても何も起きない。 #1294 で誤りにした。
573
+ *
574
+ * `見本に効かない欄` (#1308) と対になる。 2 つの表で「どちらの箱にしか効かないか」 を
575
+ * 両方向から宣言する = 片方だけ増えると鏡の関係が崩れる。
576
+ */
577
+ export const 見本にしか効かない欄 = ["state", "scale"] as const satisfies readonly (typeof ACCEPTED_KEYS.actor)[number][];
578
+
579
+ /**
580
+ * 普通の箱にしか効かない欄 (#1308)。
581
+ *
582
+ * 見本 (parts) の箱に書くと `jsonToDoc` が丸ごと落とす。 検査を通ったうえで値が消えるため、
583
+ * 書いた人には「書いたのに図が変わらない」 としか見えない (実測 = 5 欄すべてが消えていた)。
584
+ *
585
+ * 記法では同じ本文が状態の上書きに入る。 特別扱いしているのではなく、見本の中括弧に書いた
586
+ * 名前を **すべて** 状態の上書きとして読むため、結果として届いている。 JSON は欄ごとに型を
587
+ * 宣言する形なので同じ設計を持ち込めない。
588
+ *
589
+ * したがって **誤りとして返す**。 #1294 が決めた向き (見本にしか効かない `state` / `scale` を
590
+ * 普通の箱に書いたら誤り) の鏡になる。 効く書き方は `state` が既に持つため、誤りにしても
591
+ * 利用者の手段は失われない。
592
+ *
593
+ * **この表は検査と組み立ての両方が見る**。 落とす欄と誤りにする欄が別々に並ぶと、片方だけ
594
+ * 増えた時に「検査は通すが組み立てが捨てる」 状態が戻る。
595
+ */
596
+ export const 見本に効かない欄 = [
597
+ "tone",
598
+ "owner",
599
+ "end",
600
+ "touchpoint",
601
+ "opportunity",
602
+ ] as const satisfies readonly (typeof ACCEPTED_KEYS.actor)[number][];
603
+
604
+ /**
605
+ * 表に沿って 1 つの欄の値を見る (#1304)。
606
+ *
607
+ * `名前` は知らせの文に出す欄の呼び名 (`actor.tone` / `viewport.width`)。 `path` は直す場所を
608
+ * 指す JSON pointer 風の文字列で、この 2 つは役割が違う (前者は「何の欄か」、後者は「どこか」)。
609
+ */
610
+ function 値を検査(
611
+ v: unknown,
612
+ 型: 欄の型,
613
+ path: string,
614
+ 名前: string,
615
+ errors: JsonDslError[],
616
+ ): void {
617
+ const 型違い = (期待: string, 補足?: string): void => {
618
+ errors.push({
619
+ path,
620
+ message: `${名前} must be ${期待}`,
621
+ hint: 補足 ?? `got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}`,
622
+ });
623
+ };
624
+ switch (型) {
625
+ case "必須の非空文字列":
626
+ if (typeof v !== "string" || v.length === 0) 型違い("a non-empty string");
627
+ return;
628
+ case "必須の文字列":
629
+ if (typeof v !== "string") 型違い("a string");
630
+ return;
631
+ case "非空の文字列":
632
+ if (v === undefined) return;
633
+ if (typeof v !== "string" || v.length === 0) 型違い("a non-empty string if present");
634
+ return;
635
+ case "文字列":
636
+ if (v === undefined) return;
637
+ if (typeof v !== "string") 型違い("a string if present");
638
+ return;
639
+ case "文字列の並び":
640
+ if (v === undefined) return;
641
+ if (!Array.isArray(v)) {
642
+ 型違い("an array of strings if present");
643
+ return;
644
+ }
645
+ // 要素の場所まで指す。 並び全体を指すと、どれを直せばよいか読めない
646
+ v.forEach((要素, i) => {
647
+ if (typeof 要素 !== "string") {
648
+ errors.push({
649
+ path: `${path}[${i}]`,
650
+ message: `${名前}[${i}] must be a string`,
651
+ hint: `got ${要素 === null ? "null" : typeof 要素}`,
652
+ });
653
+ }
654
+ });
655
+ return;
656
+ case "数":
657
+ if (v === undefined) return;
658
+ if (typeof v !== "number" || !Number.isFinite(v)) {
659
+ 型違い(
660
+ "a finite number if present",
661
+ typeof v === "number" ? `got ${String(v)}` : undefined,
662
+ );
663
+ }
664
+ return;
665
+ case "必須の数":
666
+ if (typeof v !== "number" || !Number.isFinite(v)) {
667
+ 型違い("a finite number", typeof v === "number" ? `got ${String(v)}` : undefined);
668
+ }
669
+ return;
670
+ case "真偽":
671
+ if (v === undefined) return;
672
+ if (typeof v !== "boolean") 型違い("true or false if present");
673
+ return;
674
+ case "色":
675
+ if (v === undefined) return;
676
+ if (typeof v !== "string") {
677
+ 型違い("a color name if present");
678
+ return;
679
+ }
680
+ // 別名 (`成功` / `neutral`) も受ける。 一覧は engine から取る
681
+ if (!JSONの色名か(v)) {
682
+ errors.push({
683
+ path,
684
+ message: `${名前} must be a known color name`,
685
+ hint: `使える値 = ${書ける色名().join(", ")} (got ${JSON.stringify(v)})`,
686
+ });
687
+ }
688
+ return;
689
+ case "線種":
690
+ if (v === undefined) return;
691
+ if (typeof v !== "string" || !STYLE_VALID.has(v)) {
692
+ errors.push({
693
+ path,
694
+ message: `${名前} must be one of: ${[...STYLE_VALID].join(", ")}`,
695
+ hint: typeof v === "string" ? `got "${v}"` : `got ${typeof v}`,
696
+ });
697
+ }
698
+ return;
699
+ case "描くもの":
700
+ if (v === undefined) return;
701
+ // 受ける語は記法と同じ一覧を見る (`DRAW_WORDS`)。 写すと語が増えた時に片方だけ古くなる
702
+ if (typeof v !== "string" || !DRAW_WORDS.has(v)) {
703
+ errors.push({
704
+ path,
705
+ message: `${名前} must be one of: ${[...DRAW_WORDS].join(", ")}`,
706
+ hint: typeof v === "string" ? `got "${v}"` : `got ${typeof v}`,
707
+ });
708
+ }
709
+ return;
710
+ case "色か色番号":
711
+ if (v === undefined) return;
712
+ if (typeof v !== "string") {
713
+ 型違い("a color name or a #hex value if present");
714
+ return;
715
+ }
716
+ // 振り分けは記法と同じ (`splitColorValue`)。 `#` で始まれば色番号、それ以外は色の名前
717
+ if (v.startsWith("#")) {
718
+ if (!色番号の形.test(v)) {
719
+ errors.push({
720
+ path,
721
+ message: `${名前} must be a #hex color`,
722
+ hint: `\`#f59e0b\` の形で書く (got ${JSON.stringify(v)})`,
723
+ });
724
+ }
725
+ return;
726
+ }
727
+ if (!JSONの色名か(v)) {
728
+ errors.push({
729
+ path,
730
+ message: `${名前} must be a known color name or a #hex value`,
731
+ hint: `使える値 = ${書ける色名().join(", ")} / \`#f59e0b\` (got ${JSON.stringify(v)})`,
732
+ });
733
+ }
734
+ return;
735
+ case "必須の図種":
736
+ if (typeof v !== "string" || !VALID_PRESETS.includes(v as PresetType)) {
737
+ errors.push({
738
+ path,
739
+ message: `${名前} must be one of: ${VALID_PRESETS.join(", ")}`,
740
+ hint: typeof v === "string" ? `got "${v}"` : undefined,
741
+ });
742
+ }
743
+ return;
744
+ case "object":
745
+ case "並び":
746
+ case "必須の並び":
747
+ case "必須の非空の並び":
748
+ // 中身の形が欄ごとに違うため専用の検査が見る (`validateViewport` / `validateStates` 等)。
749
+ // ここで外側の形も見ると、同じ入力に 2 つ誤りが出てどちらを直せばよいか読めなくなる
750
+ return;
751
+ }
752
+ // 型を足して `case` を書き忘れると、その型の欄が黙って素通りする。 網羅を型検査で固定する
753
+ 型 satisfies never;
754
+ }
755
+
756
+ /**
757
+ * 1 つの階層の欄をまとめて見る (#1304)。
758
+ *
759
+ * `名前接頭` は知らせの文に出す呼び名の前半 (`actor` / `viewport` / `lanes.web`)。 空文字なら
760
+ * 欄名だけを出す (最上位の `title` 等)。
761
+ */
762
+ function 表で検査(
763
+ o: Record<string, unknown>,
764
+ 層: 階層,
765
+ path: string,
766
+ 名前接頭: string,
767
+ errors: JsonDslError[],
768
+ ): void {
769
+ // 表が `ACCEPTED_KEYS` と同じ欄を持つことは `欄の型表` の `satisfies` が固定する。 欄を
770
+ // 足して型を書き忘れると型検査が落ちるため、ここでは欠落を扱わない
771
+ for (const [欄, 型] of Object.entries<欄の型>(欄の型表[層])) {
772
+ 値を検査(o[欄], 型, `${path}.${欄}`, 名前接頭 === "" ? 欄 : `${名前接頭}.${欄}`, errors);
773
+ }
774
+ }
775
+
776
+ /**
777
+ * 綴り違いの候補を返す (#1295)。
778
+ *
779
+ * 「知らない項目です」 だけだと、`animations` と書いた人は正しい綴りを探しに行く必要がある。
780
+ * 1 文字の違い (足りない / 多い / 入れ替わり / 別の字) までを候補とする。
781
+ *
782
+ * 遠い名前は勧めない。 無関係な項目名を勧めると、書いた人がそちらへ直して二度手間になる。
150
783
  */
151
- const VALID_KIND_SET: ReadonlySet<string> = new Set([
152
- "actor", "function", "storage", "event", "cdn", "service", "database",
153
- "cache", "queue", "api", "person", "entity", "state", "container", "card",
154
- "lambda", "kms", "secret", "alb", "ecs", "rds", "s3", "iam", "user", "browser",
155
- "contract", "eoa", "multisig", "proxy", "library", "interface",
156
- ]);
157
-
158
- const VALID_PRESETS: readonly PresetType[] = [
159
- "sequence",
160
- "flow",
161
- "swimlane",
162
- "er",
163
- "state",
164
- "topology",
165
- "solidity",
166
- "gantt",
167
- "class",
168
- "pie",
169
- "c4",
170
- "mind",
171
- ] as const;
784
+ function 近い項目名(key: string, 候補: readonly string[]): string | undefined {
785
+ const 小文字 = key.toLowerCase();
786
+ let 最短: { 名: string; 距離: number } | undefined;
787
+ for (const c of 候補) {
788
+ const d = 編集距離(小文字, c.toLowerCase(), 2);
789
+ if (d <= 2 && (最短 === undefined || d < 最短.距離)) 最短 = { 名: c, 距離: d };
790
+ }
791
+ return 最短?.名;
792
+ }
793
+
794
+ /**
795
+ * 2 つの語の編集距離 (上限付き)。
796
+ *
797
+ * 上限を持つのは、長い語どうしで表を全部埋めないため。 上限を超えた時点で打ち切る。
798
+ */
799
+ function 編集距離(a: string, b: string, 上限: number): number {
800
+ if (Math.abs(a.length - b.length) > 上限) return 上限 + 1;
801
+ let 前 = Array.from({ length: b.length + 1 }, (_, i) => i);
802
+ for (let i = 1; i <= a.length; i += 1) {
803
+ const 今: number[] = [i];
804
+ let 行の最小 = i;
805
+ for (let j = 1; j <= b.length; j += 1) {
806
+ const 費用 = a[i - 1] === b[j - 1] ? 0 : 1;
807
+ const v = Math.min(今[j - 1]! + 1, 前[j]! + 1, 前[j - 1]! + 費用);
808
+ 今.push(v);
809
+ if (v < 行の最小) 行の最小 = v;
810
+ }
811
+ // その行の最小が上限を超えたら、以降どう進んでも上限以下にはならない
812
+ if (行の最小 > 上限) return 上限 + 1;
813
+ 前 = 今;
814
+ }
815
+ return 前[b.length]!;
816
+ }
817
+
818
+ /**
819
+ * 知らない項目を誤りとして積む (#1295)。
820
+ *
821
+ * 対象は plain object だけ。 形が違う入力は呼出側が別に誤りを積むため、ここでは何もしない
822
+ * (同じ入力に 2 つの誤りを出すと、どちらを直せばよいか読めなくなる)。
823
+ */
824
+ function checkUnknownKeys(v: unknown, 層: 階層, path: string, errors: JsonDslError[]): void {
825
+ if (!v || typeof v !== "object" || Array.isArray(v)) return;
826
+ const 受ける = ACCEPTED_KEYS[層] as readonly string[];
827
+ for (const key of Object.keys(v as Record<string, unknown>)) {
828
+ if (受ける.includes(key)) continue;
829
+ const 候補 = 近い項目名(key, 受ける);
830
+ errors.push({
831
+ path: `${path}.${key}`,
832
+ message: `unknown key "${key}"`,
833
+ hint: 候補 !== undefined ? `"${候補}" のことですか` : `使える項目 = ${受ける.join(", ")}`,
834
+ });
835
+ }
836
+ }
172
837
 
173
838
  /**
174
839
  * shape validation。 layer 1 = 必須 field + 型 check、 layer 2 は compile 側の validation に委譲。
@@ -185,35 +850,392 @@ function validateLayoutPos(v: unknown, path: string, errors: JsonDslError[]): vo
185
850
  return;
186
851
  }
187
852
  const p = v as Record<string, unknown>;
188
- if (typeof p.x !== "number" || !Number.isFinite(p.x)) {
189
- errors.push({ path: `${path}.x`, message: "pos.x must be a finite number" });
853
+ checkUnknownKeys(p, "layoutPos", path, errors);
854
+ 表で検査(p, "layoutPos", path, "pos", errors);
855
+ }
856
+
857
+ /**
858
+ * 見本 (parts) の名前として扱う値かどうか (#1294)。
859
+ *
860
+ * 判定は `jsonToDoc` と同じにする。 別々に書くと、検査が「見本でない」 と見た箱を
861
+ * 組み立て側が見本として扱う形ができ、見本にしか効かない項目の誤り判定がずれる。
862
+ */
863
+ function 見本の名前か(kind: unknown): boolean {
864
+ if (typeof kind !== "string" || kind.length === 0) return false;
865
+ return kind !== "actor" && !VALID_KIND_SET.has(kind);
866
+ }
867
+
868
+ /** 箱の中の要素ごとの位置と大きさを見る (#1294) */
869
+ function validateActorNodes(v: unknown, path: string, errors: JsonDslError[]): void {
870
+ if (v === undefined) return;
871
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
872
+ errors.push({
873
+ path,
874
+ message: "actor.nodes must be a plain object of name -> { posX, posY, posW, posH }",
875
+ });
876
+ return;
190
877
  }
191
- if (typeof p.y !== "number" || !Number.isFinite(p.y)) {
192
- errors.push({ path: `${path}.y`, message: "pos.y must be a finite number" });
878
+ for (const [name, o] of Object.entries(v as Record<string, unknown>)) {
879
+ const nodePath = `${path}.${name}`;
880
+ if (!o || typeof o !== "object" || Array.isArray(o)) {
881
+ errors.push({ path: nodePath, message: "actor.nodes entry must be a plain object" });
882
+ continue;
883
+ }
884
+ const n = o as Record<string, unknown>;
885
+ checkUnknownKeys(n, "actorNode", nodePath, errors);
886
+ 表で検査(n, "actorNode", nodePath, `nodes.${name}`, errors);
193
887
  }
194
888
  }
195
889
 
196
- function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: false; errors: JsonDslError[] } {
197
- const errors: JsonDslError[] = [];
198
- if (!json || typeof json !== "object" || Array.isArray(json)) {
199
- return { ok: false, errors: [{ path: "$", message: "root must be a JSON object" }] };
890
+ /**
891
+ * 図全体の大きさと間隔を見る (#1295)。
892
+ *
893
+ * これまで `viewport` は検査そのものが無く、中身を丸ごと写していた
894
+ * (`{ ...json.viewport }`)。 型にも schema にも無い `scale` が素通しで効いていた一方、
895
+ * 数でない値を書いても誰も止めなかった。
896
+ */
897
+ function validateViewport(v: unknown, errors: JsonDslError[]): void {
898
+ if (v === undefined) return;
899
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
900
+ errors.push({ path: "$.viewport", message: "viewport must be a plain object" });
901
+ return;
200
902
  }
201
- const j = json as Record<string, unknown>;
903
+ checkUnknownKeys(v, "viewport", "$.viewport", errors);
904
+ 表で検査(v as Record<string, unknown>, "viewport", "$.viewport", "viewport", errors);
905
+ }
202
906
 
203
- if (typeof j.title !== "string" || j.title.length === 0) {
204
- errors.push({ path: "$.title", message: "title must be a non-empty string" });
907
+ /**
908
+ * 鍵を利用者が決める入れ物 (縦列 / 群) を見る (#1304)
909
+ *
910
+ * `lanes` / `groups` は id を鍵に持つため、鍵そのものは縛れない。 縛れるのは
911
+ * 「入れ物が plain object か」 と「各 id の中身の項目名と値の型」 の 2 つ。
912
+ *
913
+ * #1304 まで外側の形が違う入力 (`lanes: 5`) は走査ごと飛ばされ、誤りが 1 件も返らなかった。
914
+ * 形が違うものを黙って捨てると、書いた縦列が 1 つも効かない図が知らせなしで出る。
915
+ */
916
+ function validateIdMap(
917
+ v: unknown,
918
+ 欄: "lanes" | "groups",
919
+ 層: 階層,
920
+ errors: JsonDslError[],
921
+ 中身を見る: (o: Record<string, unknown>, id: string, path: string) => void,
922
+ ): void {
923
+ if (v === undefined) return;
924
+ const path = `$.${欄}`;
925
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
926
+ errors.push({
927
+ path,
928
+ message: `${欄} must be a plain object of id -> settings`,
929
+ hint: `got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}`,
930
+ });
931
+ return;
205
932
  }
206
- // CAR-1693 Phase 1: diagram-level layout mode の validation (未指定 = auto default で backward compat)
207
- if (j.layout !== undefined && j.layout !== "auto" && j.layout !== "manual") {
208
- errors.push({ path: "$.layout", message: 'layout must be "auto" or "manual" if present' });
933
+ for (const [id, 中] of Object.entries(v as Record<string, unknown>)) {
934
+ const idPath = `${path}.${id}`;
935
+ if (!中 || typeof !== "object" || Array.isArray(中)) {
936
+ errors.push({
937
+ path: idPath,
938
+ message: `${欄}.${id} must be a plain object`,
939
+ hint: `got ${中 === null ? "null" : Array.isArray(中) ? "array" : typeof 中}`,
940
+ });
941
+ continue;
942
+ }
943
+ const o = 中 as Record<string, unknown>;
944
+ checkUnknownKeys(o, 層, idPath, errors);
945
+ 中身を見る(o, id, idPath);
209
946
  }
210
- if (typeof j.type !== "string" || !VALID_PRESETS.includes(j.type as PresetType)) {
947
+ }
948
+
949
+ /** 2 軸で仕分ける図の軸の名前を見る (#1294) */
950
+ function validateAxes(v: unknown, errors: JsonDslError[]): void {
951
+ if (v === undefined) return;
952
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
211
953
  errors.push({
212
- path: "$.type",
213
- message: `type must be one of: ${VALID_PRESETS.join(", ")}`,
214
- hint: typeof j.type === "string" ? `got "${j.type}"` : undefined,
954
+ path: "$.axes",
955
+ message: "axes must be a plain object like { x: { left, right }, y: { bottom, top } }",
215
956
  });
957
+ return;
958
+ }
959
+ const 軸 = v as Record<string, unknown>;
960
+ checkUnknownKeys(軸, "axes", "$.axes", errors);
961
+ const 層の名 = { x: "axesX", y: "axesY" } as const;
962
+ for (const 名 of ["x", "y"] as const) {
963
+ const 一方 = 軸[名];
964
+ if (一方 === undefined) continue;
965
+ if (!一方 || typeof 一方 !== "object" || Array.isArray(一方)) {
966
+ errors.push({ path: `$.axes.${名}`, message: `axes.${名} must be a plain object` });
967
+ continue;
968
+ }
969
+ const o = 一方 as Record<string, unknown>;
970
+ checkUnknownKeys(o, 層の名[名], `$.axes.${名}`, errors);
971
+ 表で検査(o, 層の名[名], `$.axes.${名}`, `axes.${名}`, errors);
972
+ }
973
+ }
974
+
975
+ /**
976
+ * 写しを作る時の入れ子の深さの上限。
977
+ *
978
+ * 枠の並びの長さが入れ子の深さで決まる。 図の入れ子は深くても数段で、 64 に届く形は書けない。
979
+ */
980
+ const 写しの最大の深さ = 64;
981
+
982
+ /**
983
+ * 写しを作る時に触る値の数の上限。
984
+ *
985
+ * **書式の規則ではなく、 資源を使い切らないための歯止め**。 図の書式は値の数を制限していないので、
986
+ * ここで拒むのは「構造としては正しいが大きすぎる」 入力になる。 だから **正当な入力が届かない
987
+ * 高さ** に置く。
988
+ *
989
+ * 500 万は、 記法の入力の大きさの上限 (`input-size.ts` の 512KB) を全て 2 文字の値で埋めても
990
+ * 届かない数になる。 JSON でも同じ規模の図が 500 万個の値を持つことはない。
991
+ *
992
+ * **数を数えないと守れない** (Round 4 の指摘)。 一度は「写しの大きさは元の入力の大きさで決まる
993
+ * から数える意味が無い」 として外したが、 これは誤りだった。 Proxy は読まれるたびに新しい object
994
+ * を返せるため、 **小さな入力から枝を生やせる** (実測 = 1 個の Proxy が深さ 6 / 6 分岐で
995
+ * 55,987 個の object に膨らんだ)。 深さの上限だけでは横の広がりを止められない。
996
+ */
997
+ const 写しの最大の項目数 = 5_000_000;
998
+
999
+ /** 写しを作れなかった理由 (path 付き) */
1000
+ class 写せない extends Error {
1001
+ constructor(
1002
+ readonly path: string,
1003
+ readonly 理由: string,
1004
+ ) {
1005
+ super(`${path}: ${理由}`);
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * 検査の前に 1 度だけ読んで作る、 素のデータの複製 (#1217)。
1011
+ *
1012
+ * 入口は検査する時と図に写す時で同じ項目を 2 度読んでいた。 渡された object が値を返す関数
1013
+ * (getter) を持っていると、 2 度目の読み取りで別の値を返せる = **検査を通った値と図に届く値が
1014
+ * 別物になり、 検査が意味を持たない** (実測 = `animation[0].tween` を 6 回目から
1015
+ * `[NaN, Infinity]` を返す getter にすると、 検査を通って図に `from: null` が届いた)。
1016
+ *
1017
+ * ここで 1 度だけ読んで写しを作り、 以降は写しだけを読む。 各項目の読み取りは 1 回で、
1018
+ * 項目の名前も添字も同じ値を 2 度取りに行かない。
1019
+ *
1020
+ * **`structuredClone` は使わない**。 関数や symbol を含む入力で `DataCloneError` を投げるため、
1021
+ * `validateDragonJson` が約束している「誤りは `{ ok: false, errors }` で返す」 が破れる。
1022
+ * 自前で写せば、 写せない値もそのまま持ち越して検査側の型の判定に落とせる。
1023
+ *
1024
+ * **再帰では書かない** (Round 1 の指摘)。 検査が見ない項目も含めて写すため、 深い入れ子を渡すと
1025
+ * 呼び出しの積み上げが溢れる (実測 = 使わない項目に 20,000 段の入れ子を付けると
1026
+ * `RangeError: Maximum call stack size exceeded`)。 枠を自前で積んで回す。
1027
+ *
1028
+ * **読む順は書いた順のまま、 深さ優先で降りる** (Round 2 / 3 の指摘)。 値を返す関数が副作用を
1029
+ * 持つ入力では読む順が結果に出るため、 再帰で書いた時と同じ順を保つ。 幅優先で回すと、 先に
1030
+ * 書いた兄弟の深い所より後の兄弟の浅い所を先に読む。
1031
+ *
1032
+ * **読み取りの例外も外に出さない** (Round 1 の指摘)。 項目の名前を数える所も値を読む所も、
1033
+ * getter や Proxy が投げれば `validateDragonJson` 自体が throw して約束が破れる。 投げた場所を
1034
+ * path として拾い、 検査の誤りに変える。
1035
+ *
1036
+ * 書き込みは `Object.defineProperty` で行う = `__proto__` を項目名に持つ入力で代入が
1037
+ * prototype の setter に落ちるのを避ける (`JSON.parse` と同じく普通の項目として持つ)。
1038
+ * `__proto__` を書いた時の扱いそのものは `#1184` が持つ。
1039
+ *
1040
+ * 輪 (自分を指す入れ子) は同じ写しを返して止める。 JSON からは作れないが、 object を直接
1041
+ * 渡す経路では作れる。
1042
+ *
1043
+ * ## 守る範囲 (Round 6 で線を引いた)
1044
+ *
1045
+ * この関数が守るのは **自分が確保する量** = 写しの入れ物と、 名前の一覧と、 枠の並び。 いずれも
1046
+ * 上限 (深さ / 数) を見てから作る。
1047
+ *
1048
+ * **渡された側が自分で確保する量は守れない**。 Proxy の `ownKeys` は「名前の並びを返す」 のが
1049
+ * 仕事で、 その並びは trap の中で作られる。 こちらが受け取った時点で既に在るため、 長さを見て
1050
+ * 拒んでも確保そのものは起きた後になる。 これは呼ぶ側の code が確保するもので、 同じ process に
1051
+ * 任意の object を渡せる相手は、 この関数を通さずに同じことができる。
1052
+ *
1053
+ * 線を引くのは、 5 round にわたって「読む前に量を作れる経路」 を潰し続けた末に、 残りが
1054
+ * 呼ぶ側の code の中に移ったため。 潰す対象が自分の外に出た時点で、 この関数の責務ではない。
1055
+ *
1056
+ * **test のために export する** (#1295)。 写しの性質 (深さ / 数の上限 / 読む順 / 値を返す
1057
+ * 関数の扱い) を確かめる検査は、以前は「検査が見ない項目」 に構造をぶら下げて
1058
+ * `validateDragonJson` 越しに見ていた。 知らない項目を誤りにしたことでその足場が無くなり、
1059
+ * かつ写しは検査より前に走るため、検査の結果からは写しの中身を取り出せない。
1060
+ * 検査の対象そのものを直接呼ぶ形にする。
1061
+ */
1062
+ export function 素のデータに写す(
1063
+ root: unknown,
1064
+ ): { ok: true; value: unknown } | { ok: false; error: JsonDslError } {
1065
+ const 写し済 = new WeakMap<object, unknown[] | Record<string, unknown>>();
1066
+ let 項目数 = 0;
1067
+ // 例外を拾った時に「どこを読んでいたか」 を言うために持つ。 投げるのは値を読む所と名前を
1068
+ // 数える所の両方で、 どちらも path を持たないまま外へ出ると `$` としか言えない
1069
+ let 読んでいる場所 = "$";
1070
+
1071
+ /**
1072
+ * まだ中身を埋めていない入れ物と、 その進み具合。
1073
+ *
1074
+ * 配列は名前の並びを持たず長さだけを持つ (Round 6 の指摘)。 添字を文字の並びとして実体化すると、
1075
+ * **上限を見る前にその並びを作ってしまう** (実測 = `new Array(5_000_001)` で 500 万個の添字を
1076
+ * 作ろうとした)。 添字は数から導けるので持つ必要がない。
1077
+ */
1078
+ type 枠 = {
1079
+ 元: object;
1080
+ 器: unknown[] | Record<string, unknown>;
1081
+ /** object の時だけ持つ。 配列は `長さ` を使う */
1082
+ 名前の並び: string[] | null;
1083
+ 長さ: number;
1084
+ 次: number;
1085
+ 深さ: number;
1086
+ path: string;
1087
+ };
1088
+
1089
+ /** 入れ物だけ作る (ここでは降りない)。 新しく作った時だけ枠を返す */
1090
+ const 器を作る = (v: unknown, 深さ: number, path: string): { 値: unknown; 枠: 枠 | null } => {
1091
+ 項目数 += 1;
1092
+ if (項目数 > 写しの最大の項目数) {
1093
+ throw new 写せない(path, `項目が多すぎる (上限 ${写しの最大の項目数})`);
1094
+ }
1095
+ if (v === null || typeof v !== "object") return { 値: v, 枠: null };
1096
+
1097
+ const 既にある = 写し済.get(v);
1098
+ if (既にある !== undefined) return { 値: 既にある, 枠: null };
1099
+
1100
+ if (深さ >= 写しの最大の深さ) {
1101
+ throw new 写せない(path, `入れ子が深すぎる (上限 ${写しの最大の深さ})`);
1102
+ }
1103
+ 読んでいる場所 = path;
1104
+ const 並びか = Array.isArray(v);
1105
+ const 器: unknown[] | Record<string, unknown> = 並びか ? [] : {};
1106
+ 写し済.set(v, 器);
1107
+
1108
+ if (並びか) {
1109
+ // **長さを先に見てから降りる** (Round 6 の指摘)。 添字を文字の並びとして作ると、 上限を
1110
+ // 見る前にその並びを作ってしまう。 長さは数を読むだけなので何も作らない。
1111
+ //
1112
+ // **長さは正規化してから使う** (Round 7 の指摘)。 `Array.from({ length })` は仕様の
1113
+ // `ToLength` を通しており、 生の値をそのまま使うと 2 つの形で壊れる。
1114
+ //
1115
+ // | `length` が返す値 | 正規化しないと |
1116
+ // |---|---|
1117
+ // | `2.5` | 3 回読む (`Array.from` は 2 要素) |
1118
+ // | `NaN` | 数の合計が `NaN` になり、 上限も終わりも判定できず読み続ける |
1119
+ //
1120
+ // `ToLength` と同じく 0 へ丸め、 0 以上 2^53-1 以下に収める。
1121
+ //
1122
+ // **数に直すのは単項 `+`** (Round 8 の指摘)。 `Number()` は `BigInt` を通してしまうが、
1123
+ // 仕様の `ToNumber` は `TypeError` を投げる = `Array.from({ length: 2n })` は投げる。
1124
+ // 単項 `+` は `ToNumber` そのものなので、 投げる形も含めて元の挙動と揃う (投げた分は
1125
+ // 下の `catch` が検査の誤りに変える)。
1126
+ const 生の長さ = +(v as unknown[]).length;
1127
+ const 長さ = Number.isNaN(生の長さ)
1128
+ ? 0
1129
+ : Math.min(Math.max(Math.trunc(生の長さ), 0), Number.MAX_SAFE_INTEGER);
1130
+ 項目数 += 長さ;
1131
+ if (項目数 > 写しの最大の項目数) {
1132
+ throw new 写せない(path, `項目が多すぎる (上限 ${写しの最大の項目数})`);
1133
+ }
1134
+ return { 値: 器, 枠: { 元: v, 器, 名前の並び: null, 長さ, 次: 0, 深さ, path } };
1135
+ }
1136
+
1137
+ // **名前も数に入れる** (Round 5 の指摘)。 値を読む前に名前の一覧を作るため、 値だけを
1138
+ // 数えると「名前が 20,000 個ある段を 63 回降りる」 形で 126 万個を並べられる = 上限を
1139
+ // 見る前に資源を使い切れる
1140
+ const 名前の並び = Object.keys(v as Record<string, unknown>);
1141
+ 項目数 += 名前の並び.length;
1142
+ if (項目数 > 写しの最大の項目数) {
1143
+ throw new 写せない(path, `項目が多すぎる (上限 ${写しの最大の項目数})`);
1144
+ }
1145
+ return { 値: 器, 枠: { 元: v, 器, 名前の並び, 長さ: 名前の並び.length, 次: 0, 深さ, path } };
1146
+ };
1147
+
1148
+ try {
1149
+ const 先頭 = 器を作る(root, 0, "$");
1150
+ const 積み: 枠[] = 先頭.枠 ? [先頭.枠] : [];
1151
+
1152
+ while (積み.length > 0) {
1153
+ const 今 = 積み[積み.length - 1]!;
1154
+ if (今.次 >= 今.長さ) {
1155
+ 積み.pop();
1156
+ continue;
1157
+ }
1158
+ // 配列は添字をその場で作る (並びとして持たない)
1159
+ const key = 今.名前の並び === null ? String(今.次) : 今.名前の並び[今.次]!;
1160
+ 今.次 += 1;
1161
+ const 子のpath = 今.名前の並び === null ? `${今.path}[${key}]` : `${今.path}.${key}`;
1162
+
1163
+ // 読む直前に場所を控える = 値の読み取りそのものが投げるため、 読んだ後では遅い
1164
+ 読んでいる場所 = 子のpath;
1165
+ const 生の値 = (今.元 as Record<string, unknown>)[key];
1166
+
1167
+ const 子 = 器を作る(生の値, 今.深さ + 1, 子のpath);
1168
+ if (Array.isArray(今.器)) 今.器.push(子.値);
1169
+ else {
1170
+ Object.defineProperty(今.器, key, {
1171
+ value: 子.値,
1172
+ enumerable: true,
1173
+ writable: true,
1174
+ configurable: true,
1175
+ });
1176
+ }
1177
+ // 深さ優先で降りる = 次の兄弟を読む前に、 この子の中身を全部読む
1178
+ if (子.枠) 積み.push(子.枠);
1179
+ }
1180
+ return { ok: true, value: 先頭.値 };
1181
+ } catch (e) {
1182
+ if (e instanceof 写せない) {
1183
+ return { ok: false, error: { path: e.path, message: e.理由 } };
1184
+ }
1185
+ // getter / Proxy が投げた形。 約束どおり誤りとして返す (throw しない)
1186
+ return {
1187
+ ok: false,
1188
+ error: {
1189
+ path: 読んでいる場所,
1190
+ message: "入力を読み取れない",
1191
+ hint: e instanceof Error ? e.message : String(e),
1192
+ },
1193
+ };
1194
+ }
1195
+ }
1196
+
1197
+ function validateJson(
1198
+ json: unknown,
1199
+ ): { ok: true; data: DragonJson } | { ok: false; errors: JsonDslError[] } {
1200
+ const errors: JsonDslError[] = [];
1201
+ // root の形は写しより先に見る = 形が違う入力には従来どおり `root must be a JSON object` を
1202
+ // 返すため。 写した後に見ると、 root が配列の入力で中の getter が先に動き、 別の誤りに化ける
1203
+ // (Round 3 の指摘)。
1204
+ //
1205
+ // ただし `Array.isArray` は失効した Proxy で `TypeError` を投げる (Round 2 の指摘)。 判定
1206
+ // そのものを受けて、 投げた形は「読み取れない」 として返す。
1207
+ let rootがobjectか: boolean;
1208
+ try {
1209
+ rootがobjectか = !!json && typeof json === "object" && !Array.isArray(json);
1210
+ } catch (e) {
1211
+ return {
1212
+ ok: false,
1213
+ errors: [
1214
+ {
1215
+ path: "$",
1216
+ message: "入力を読み取れない",
1217
+ hint: e instanceof Error ? e.message : String(e),
1218
+ },
1219
+ ],
1220
+ };
216
1221
  }
1222
+ if (!rootがobjectか) {
1223
+ return { ok: false, errors: [{ path: "$", message: "root must be a JSON object" }] };
1224
+ }
1225
+
1226
+ // 以降は写しだけを読む。 元の object には二度と触らない (#1217)
1227
+ const 写し = 素のデータに写す(json);
1228
+ if (!写し.ok) return { ok: false, errors: [写し.error] };
1229
+ const j = 写し.value as Record<string, unknown>;
1230
+
1231
+ // 知らない項目を先に見る (#1295)。 綴り違いは「書いた項目が効かない」 形で表に出るため、
1232
+ // 個々の型の誤りより先に伝える方が直しやすい
1233
+ checkUnknownKeys(j, "root", "$", errors);
1234
+
1235
+ // 値そのものの型は表が見る (#1304)。 図表の箱の上の小見出し (#1247) の空文字は
1236
+ // 「書かなかった」 と同じ扱いにするため通す (記法側の `eyebrow:` と揃える。 落とすのは `jsonToDoc`)
1237
+ 表で検査(j, "root", "$", "", errors);
1238
+
217
1239
  if (!Array.isArray(j.actors) || j.actors.length === 0) {
218
1240
  errors.push({ path: "$.actors", message: "actors must be a non-empty array" });
219
1241
  } else {
@@ -224,19 +1246,49 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
224
1246
  return;
225
1247
  }
226
1248
  const ao = a as Record<string, unknown>;
227
- if (typeof ao.name !== "string" || ao.name.length === 0) {
228
- errors.push({ path: `$.actors[${i}].name`, message: "actor.name must be a non-empty string" });
1249
+ checkUnknownKeys(ao, "actor", `$.actors[${i}]`, errors);
1250
+ // 値そのものの型は表が見る (#1304)。 `kind` は見本 (parts) の名前も受けるため
1251
+ // 非空の文字列までしか縛らない (CAR-1657 の unified syntax)
1252
+ 表で検査(ao, "actor", `$.actors[${i}]`, "actor", errors);
1253
+ // 見本 (parts) にしか効かない項目は、見本でない箱に書かれたら誤りにする (#1294)。
1254
+ // 記法側は読めない項目名として行番号付きで知らせるため、黙って捨てると入口で扱いが変わる。
1255
+ const 見本か = 見本の名前か(ao.kind);
1256
+ // 見本にしか効かない欄を普通の箱に書いた形 (#1294)。 一覧は表が持つ
1257
+ const 代わりに使う欄: Record<(typeof 見本にしか効かない欄)[number], string> = {
1258
+ state: "箱の見た目を変えるなら tone / color を使う",
1259
+ scale: "大きさを変えるなら posW / posH を使う",
1260
+ };
1261
+ if (!見本か) {
1262
+ for (const 欄 of 見本にしか効かない欄) {
1263
+ if (ao[欄] === undefined) continue;
1264
+ errors.push({
1265
+ path: `$.actors[${i}].${欄}`,
1266
+ message: `actor.${欄} is only for parts (kind must be a parts identifier)`,
1267
+ hint: 代わりに使う欄[欄],
1268
+ });
1269
+ }
229
1270
  }
230
- // CAR-1657 (+ codex-review MAJOR fix) = kind の validation、 non-empty string 必須。
231
- // parts identifier or existing NodeKind のどちらかを想定、 空文字 or 非 string は reject。
232
- if (ao.kind !== undefined && (typeof ao.kind !== "string" || ao.kind.length === 0)) {
233
- errors.push({ path: `$.actors[${i}].kind`, message: "actor.kind must be a non-empty string" });
1271
+ // 逆向きも同じく誤りにする (#1308) 普通の箱にしか効かない欄を見本に書くと、
1272
+ // `jsonToDoc` が丸ごと落として何も起きない
1273
+ if (見本か) {
1274
+ for (const of 見本に効かない欄) {
1275
+ if (ao[欄] === undefined) continue;
1276
+ errors.push({
1277
+ path: `$.actors[${i}].${欄}`,
1278
+ message: `actor.${欄} is not for parts (kind is a parts identifier)`,
1279
+ hint: `見本の状態を変えるなら state を使う (\`"state": { "${欄}": ... }\`)`,
1280
+ });
1281
+ }
234
1282
  }
1283
+ validateActorNodes(ao.nodes, `$.actors[${i}].nodes`, errors);
235
1284
  // codex-review MAJOR fix = state override は plain object + 値は primitive (number / string / boolean) 限定、
236
1285
  // `{ v: {} }` 等 nested object や null が流入すると CdlState.initial に不正な型が入り compile 崩れる。
237
1286
  if (ao.state !== undefined) {
238
1287
  if (!ao.state || typeof ao.state !== "object" || Array.isArray(ao.state)) {
239
- errors.push({ path: `$.actors[${i}].state`, message: "actor.state must be a plain object" });
1288
+ errors.push({
1289
+ path: `$.actors[${i}].state`,
1290
+ message: "actor.state must be a plain object",
1291
+ });
240
1292
  } else {
241
1293
  for (const [sk, sv] of Object.entries(ao.state as Record<string, unknown>)) {
242
1294
  const svType = typeof sv;
@@ -262,21 +1314,26 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
262
1314
  return;
263
1315
  }
264
1316
  const so = s as Record<string, unknown>;
265
- if (typeof so.from !== "string") errors.push({ path: `$.flow[${i}].from`, message: "step.from must be a string" });
266
- if (typeof so.to !== "string") errors.push({ path: `$.flow[${i}].to`, message: "step.to must be a string" });
267
- if (typeof so.label !== "string") errors.push({ path: `$.flow[${i}].label`, message: "step.label must be a string" });
1317
+ checkUnknownKeys(so, "step", `$.flow[${i}]`, errors);
1318
+ // 値そのものの型は表が見る (#1304) 色と線種は engine の一覧と突き合わせる
1319
+ 表で検査(so, "step", `$.flow[${i}]`, "step", errors);
268
1320
  // CAR-1693 Phase 1: step DSL 表面 pos の validation
269
1321
  validateLayoutPos(so.pos, `$.flow[${i}].pos`, errors);
270
1322
  });
271
1323
  }
272
- // CAR-1693 Phase 1: lane DSL 表面 pos の validation
273
- if (j.lanes !== undefined && j.lanes && typeof j.lanes === "object" && !Array.isArray(j.lanes)) {
274
- for (const [laneId, lane] of Object.entries(j.lanes as Record<string, unknown>)) {
275
- if (lane && typeof lane === "object" && !Array.isArray(lane)) {
276
- validateLayoutPos((lane as Record<string, unknown>).pos, `$.lanes.${laneId}.pos`, errors);
277
- }
278
- }
279
- }
1324
+ validateViewport(j.viewport, errors);
1325
+ // 縦列と群は **鍵を利用者が決める** (id) 表が縛るのはその中の項目
1326
+ //
1327
+ // #1304 まで外側の形すら見ておらず、`lanes: 5` のような値が誤りにならないまま素通りしていた
1328
+ // (形が違えば中の走査ごと飛ばす書き方だったため) 形が違う値は黙って捨てない
1329
+ validateIdMap(j.lanes, "lanes", "lane", errors, (lane, laneId, path) => {
1330
+ 表で検査(lane, "lane", path, `lanes.${laneId}`, errors);
1331
+ // CAR-1693 Phase 1: lane DSL 表面 pos の validation
1332
+ validateLayoutPos(lane.pos, `${path}.pos`, errors);
1333
+ });
1334
+ validateIdMap(j.groups, "groups", "group", errors, (group, groupId, path) => {
1335
+ 表で検査(group, "group", path, `groups.${groupId}`, errors);
1336
+ });
280
1337
  if (j.animation !== undefined) {
281
1338
  if (!Array.isArray(j.animation)) {
282
1339
  errors.push({ path: "$.animation", message: "animation must be an array if present" });
@@ -287,22 +1344,163 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
287
1344
  return;
288
1345
  }
289
1346
  const po = p as Record<string, unknown>;
290
- if (typeof po.step !== "string" || po.step.length === 0) {
291
- errors.push({ path: `$.animation[${i}].step`, message: "phase.step must be a non-empty string" });
292
- }
1347
+ checkUnknownKeys(po, "phase", `$.animation[${i}]`, errors);
1348
+ // 値そのものの型は表が見る (#1304)
1349
+ 表で検査(po, "phase", `$.animation[${i}]`, "phase", errors);
1350
+ validatePhaseMotion(po, i, errors);
293
1351
  });
294
1352
  }
295
1353
  }
1354
+ validateStates(j.states, errors);
1355
+ validateValues(j.values, errors);
1356
+ validateAxes(j.axes, errors);
296
1357
  if (errors.length > 0) return { ok: false, errors };
297
1358
  return { ok: true, data: j as unknown as DragonJson };
298
1359
  }
299
1360
 
1361
+ /**
1362
+ * 段の中で値を動かす指定を見る (#1186)。
1363
+ *
1364
+ * 状態名の記法は `states:` と同じ判定を使う。 参照先はこの JSON の `states` だけでは決めない。
1365
+ * 見本や preset が持つ状態を動かす指定もあるためで、記法側と同じく compile 後の図で解決する。
1366
+ */
1367
+ function validatePhaseMotion(po: Record<string, unknown>, i: number, errors: JsonDslError[]): void {
1368
+ if (po.tween !== undefined) {
1369
+ if (!po.tween || typeof po.tween !== "object" || Array.isArray(po.tween)) {
1370
+ errors.push({
1371
+ path: `$.animation[${i}].tween`,
1372
+ message: "tween must be a plain object of state -> [from, to]",
1373
+ });
1374
+ } else {
1375
+ for (const [name, range] of Object.entries(po.tween as Record<string, unknown>)) {
1376
+ const path = `$.animation[${i}].tween.${name}`;
1377
+ if (!isValueName(name)) errors.push({ path, ...valueNameIssue(name) });
1378
+ if (!Array.isArray(range) || range.length !== 2) {
1379
+ errors.push({ path, message: "tween value must be [from, to]" });
1380
+ continue;
1381
+ }
1382
+ // 補間は数どうしでしか成り立たない。 文字列を通すと描画側が数として読めず
1383
+ // 段の途中が壊れる (記法側も数だけを受ける)
1384
+ for (const v of range) {
1385
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1386
+ errors.push({
1387
+ path,
1388
+ message: "tween value must be finite numbers",
1389
+ hint: `got ${typeof v}`,
1390
+ });
1391
+ break;
1392
+ }
1393
+ }
1394
+ }
1395
+ }
1396
+ }
1397
+
1398
+ if (po.set !== undefined) {
1399
+ if (!po.set || typeof po.set !== "object" || Array.isArray(po.set)) {
1400
+ errors.push({
1401
+ path: `$.animation[${i}].set`,
1402
+ message: "set must be a plain object of state -> value",
1403
+ });
1404
+ } else {
1405
+ for (const [name, value] of Object.entries(po.set as Record<string, unknown>)) {
1406
+ const path = `$.animation[${i}].set.${name}`;
1407
+ if (!isValueName(name)) errors.push({ path, ...valueNameIssue(name) });
1408
+ const t = typeof value;
1409
+ if (t !== "number" && t !== "string") {
1410
+ errors.push({ path, message: "set value must be a number or string", hint: `got ${t}` });
1411
+ } else if (t === "number" && !Number.isFinite(value as number)) {
1412
+ errors.push({ path, message: "set value must be a finite number" });
1413
+ }
1414
+ }
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ /**
1420
+ * 状態の初期値を見る (#1181)。
1421
+ *
1422
+ * 名前の判定は記法と同じものを使う (`value-syntax.ts`)。 別々に持つと、YAML では弾かれる
1423
+ * 名前が JSON では通る形ができ、描画側が `{名前}` を置き換えられない図が生まれる。
1424
+ */
1425
+ function validateStates(v: unknown, errors: JsonDslError[]): void {
1426
+ if (v === undefined) return;
1427
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
1428
+ errors.push({
1429
+ path: "$.states",
1430
+ message: "states must be a plain object of name -> initial value",
1431
+ });
1432
+ return;
1433
+ }
1434
+ for (const [name, initial] of Object.entries(v as Record<string, unknown>)) {
1435
+ if (!isValueName(name)) {
1436
+ errors.push({ path: `$.states.${name}`, ...valueNameIssue(name) });
1437
+ }
1438
+ const t = typeof initial;
1439
+ if (t !== "number" && t !== "string") {
1440
+ errors.push({
1441
+ path: `$.states.${name}`,
1442
+ message: "state initial must be a number or string",
1443
+ hint: `got ${t}`,
1444
+ });
1445
+ } else if (t === "number" && !Number.isFinite(initial as number)) {
1446
+ // `NaN` / `Infinity` は JSON には書けないが、object を直接渡す経路では届く。
1447
+ // 描画側は文字列に直して式に流すため、そのまま通すと計算が全て壊れる
1448
+ errors.push({ path: `$.states.${name}`, message: "state initial must be a finite number" });
1449
+ }
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * 他の値から決まる値を見る (#1181)。
1455
+ *
1456
+ * 名前と式の判定は記法と同じものを使う。 式が文法として正しいかまでは見ない (描画側が
1457
+ * 評価する時に判定して、その値だけを止める = spec § 4.2)。
1458
+ */
1459
+ function validateValues(v: unknown, errors: JsonDslError[]): void {
1460
+ if (v === undefined) return;
1461
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
1462
+ errors.push({
1463
+ path: "$.values",
1464
+ message: "values must be a plain object of name -> expression",
1465
+ });
1466
+ return;
1467
+ }
1468
+ for (const [name, expression] of Object.entries(v as Record<string, unknown>)) {
1469
+ if (!isValueName(name)) {
1470
+ errors.push({ path: `$.values.${name}`, ...valueNameIssue(name) });
1471
+ }
1472
+ if (typeof expression !== "string" || expression.trim() === "") {
1473
+ errors.push({
1474
+ path: `$.values.${name}`,
1475
+ message: "value expression must be a non-empty string",
1476
+ hint: '`"{inflow} - {done}"` の形で書く',
1477
+ });
1478
+ continue;
1479
+ }
1480
+ for (const issue of checkValueExpression(expression, name)) {
1481
+ errors.push({ path: `$.values.${name}`, ...issue });
1482
+ }
1483
+ }
1484
+ }
1485
+
300
1486
  /**
301
1487
  * JSON DSL → DslDocument (AST) 変換。 pos は JSON なので line 情報なし、 全て line 0。
302
1488
  *
303
1489
  * CAR-1693 Phase 1: DSL 表面 `pos: {x, y}` → 内部 AST `layoutPos:` の 2 層 mapping の実装 core。
304
1490
  * test で mapping logic を実 execute するため export する (pos-field.test.ts の regression guard)。
305
1491
  */
1492
+ /**
1493
+ * 図表の箱の上の小見出しを、 記法側と同じ形に整える (#1247)。
1494
+ *
1495
+ * 記法は値を `trim()` してから空かどうかを見る。 JSON でも同じ順で見ないと、 空白だけの値が
1496
+ * 入口によって別の意味になる (記法は書かなかった扱い、 JSON は中身のない帯)。
1497
+ */
1498
+ function 整えた小見出し(v: string | undefined): string | undefined {
1499
+ if (v === undefined) return undefined;
1500
+ const t = v.trim();
1501
+ return t.length > 0 ? t : undefined;
1502
+ }
1503
+
306
1504
  export function jsonToDoc(json: DragonJson): DslDocument {
307
1505
  const p0 = { line: 0 };
308
1506
  const actors: DslActor[] = json.actors.map((a) => {
@@ -312,9 +1510,12 @@ export function jsonToDoc(json: DragonJson): DslDocument {
312
1510
  // CAR-1657 = kind が既存 NodeKind に無い値なら parts identifier 候補、 partId に格納
313
1511
  const kindStr = (a.kind ?? "actor") as string;
314
1512
  const isPart = kindStr !== "actor" && !VALID_KIND_SET.has(kindStr);
1513
+ // 色は記法と同じ振り分けを通す (#1294)。 `#` で始まれば色番号、それ以外は色の名前。
1514
+ // 別々に書くと、同じ値が入口によって色番号にも色名にもなる
1515
+ const 色 = a.color !== undefined ? splitColorValue(a.color) : {};
315
1516
  return {
316
1517
  name: a.name,
317
- kind: isPart ? "actor" as NodeKind : (a.kind ?? "actor") as NodeKind,
1518
+ kind: isPart ? "actor" : resolveNodeKind(kindStr),
318
1519
  // parts 候補は `kind` を `actor` に倒して `partId` へ退避するため、 名札に載せる種類としては
319
1520
  // 「書かなかった」 と同じ扱いにする (#1058)
320
1521
  kindWritten: a.kind !== undefined && !isPart,
@@ -326,6 +1527,37 @@ export function jsonToDoc(json: DragonJson): DslDocument {
326
1527
  stack: a.stack,
327
1528
  initial: a.initial,
328
1529
  final: a.final,
1530
+ // 普通の箱にしか効かない欄は見本では落とす。 落とす欄の一覧は `見本に効かない欄` が
1531
+ // 唯一の出どころで、検査 (#1308) も同じ表を見る = 「検査は通すが組み立てが捨てる」
1532
+ // 状態が作れない
1533
+ ...(isPart
1534
+ ? {}
1535
+ : {
1536
+ tone: resolveTone(a.tone) ?? 色.tone,
1537
+ owner: a.owner,
1538
+ end: a.end,
1539
+ touchpoint: a.touchpoint,
1540
+ opportunity: a.opportunity,
1541
+ }),
1542
+ colorHex: 色.hex,
1543
+ // 絶対座標と大きさ (#1294)。 `pos` (ずらし幅) とは別経路
1544
+ posX: a.posX,
1545
+ posY: a.posY,
1546
+ posW: a.posW,
1547
+ posH: a.posH,
1548
+ // 箱の中の要素ごとの固定 (#1294)。 写しを作って外から書き換えられないようにする
1549
+ nodes: a.nodes
1550
+ ? Object.fromEntries(
1551
+ Object.entries(a.nodes).map(([id, o]) => [
1552
+ id,
1553
+ { posX: o.posX, posY: o.posY, posW: o.posW, posH: o.posH },
1554
+ ]),
1555
+ )
1556
+ : undefined,
1557
+ // 倍率は見本にしか効かない (検査が見本でない箱を弾く)。 書かれた名前は JSON では
1558
+ // 常に `scale` で、記法の別名 (`倍率`) は JSON に持ち込まない
1559
+ scale: isPart ? a.scale : undefined,
1560
+ scaleKeys: isPart && a.scale !== undefined ? ["scale"] : undefined,
329
1561
  partId: isPart ? kindStr : undefined,
330
1562
  stateOverride: isPart ? a.state : undefined,
331
1563
  // CAR-1693 Phase 1: DSL 表面 pos → 内部 AST layoutPos の 2 層 mapping (naming collision 回避)
@@ -339,34 +1571,75 @@ export function jsonToDoc(json: DragonJson): DslDocument {
339
1571
  to: s.to,
340
1572
  label: s.label,
341
1573
  sub: s.sub,
342
- tone: s.tone,
1574
+ // 箱と同じ読み替えを通す (#1304)。 通さないと `tone: "成功"` が色名として解決されないまま
1575
+ // 図に届き、同じ値が箱では色になり矢印では色にならない
1576
+ tone: resolveTone(s.tone),
343
1577
  style: s.style,
344
1578
  guard: s.guard,
345
1579
  cardinality: s.cardinality,
346
1580
  labelOffsetX: s.labelOffsetX,
347
1581
  labelOffsetY: s.labelOffsetY,
1582
+ overlay: s.overlay,
348
1583
  // CAR-1693 Phase 1: DSL 表面 pos → 内部 AST layoutPos
349
1584
  layoutPos: s.pos,
350
1585
  pos: p0,
351
1586
  }));
352
- let animate: DslAnimate | undefined;
353
- if (json.animation && json.animation.length > 0) {
354
- const phases: DslPhase[] = json.animation.map((p) => ({
355
- name: p.step,
356
- durationMs: Math.round((p.duration ?? 1.4) * 1000),
357
- highlight: p.focus,
358
- body: p.body,
359
- badge: p.badge,
360
- pos: p0,
361
- }));
362
- animate = { states: [], phases, pos: p0 };
363
- }
1587
+ // 状態は段が無くても図に載る (#1162 で組み立ての出口が載せる)。 **段の有無で分けない** =
1588
+ // 分けると `states` だけを書いた JSON で値が 1 つも届かない (記法側で起きていた形、 #1181)
1589
+ const states: DslState[] = Object.entries(json.states ?? {}).map(([name, initial]) => ({
1590
+ name,
1591
+ initial,
1592
+ pos: p0,
1593
+ }));
1594
+ const phases: DslPhase[] = (json.animation ?? []).map((p) => ({
1595
+ name: p.step,
1596
+ durationMs: Math.round((p.duration ?? 1.4) * 1000),
1597
+ highlight: p.focus,
1598
+ body: p.body,
1599
+ badge: p.badge,
1600
+ // 書いた段だけが欄を持つ。 空文字を置くと「書いた」 と「書いていない」 が同じ形になる
1601
+ ...(p.draw !== undefined ? { draw: p.draw, drawPos: p0 } : {}),
1602
+ // 段の中で動かす分 (#1186)。 記法側の `tweens` / `sets` と同じ形に写す。
1603
+ // 空の配列を置かないのは、記法側が「無ければ field ごと持たない」 形だから
1604
+ ...(p.tween && Object.keys(p.tween).length > 0
1605
+ ? {
1606
+ tweens: Object.entries(p.tween).map(([state, [from, to]]) => ({
1607
+ state,
1608
+ from,
1609
+ to,
1610
+ pos: p0,
1611
+ })),
1612
+ }
1613
+ : {}),
1614
+ ...(p.set && Object.keys(p.set).length > 0
1615
+ ? { sets: Object.entries(p.set).map(([state, value]) => ({ state, value, pos: p0 })) }
1616
+ : {}),
1617
+ pos: p0,
1618
+ }));
1619
+ const animate: DslAnimate | undefined =
1620
+ states.length > 0 || phases.length > 0 ? { states, phases, pos: p0 } : undefined;
364
1621
  return {
365
1622
  title: json.title,
366
1623
  type: json.type,
1624
+ // 前後の空白を落としてから見る。 記法側 (`v05/parser.ts`) が `trim()` してから
1625
+ // 空かどうかを判定するため、 揃えないと **空白だけの値で入口ごとに図が変わる**
1626
+ // (記法は書かなかった扱い、 JSON は中身のない帯を描く。 Round 2 の指摘で実測)
1627
+ ...(整えた小見出し(json.eyebrow) !== undefined
1628
+ ? { eyebrow: 整えた小見出し(json.eyebrow), eyebrowPos: p0 }
1629
+ : {}),
1630
+ // 2 軸で仕分ける図の軸の名前 (#1294)。 中身の無い形は「書かなかった」 と同じにする =
1631
+ // 空の軸を渡すと、書いていない側の名前が空文字で描かれる (記法側と同じ扱い)
1632
+ ...(json.axes && (json.axes.x !== undefined || json.axes.y !== undefined)
1633
+ ? { axes: json.axes, axesPos: p0 }
1634
+ : {}),
367
1635
  actors,
368
1636
  flow,
369
1637
  animate,
1638
+ // 他の値から決まる値 (#1181)。 書いた順に並べる = 解く順は参照から決まるので順序に
1639
+ // 意味は無いが、知らせの並びが書いた順になる
1640
+ values: json.values
1641
+ ? Object.entries(json.values).map(([name, expression]) => ({ name, expression, pos: p0 }))
1642
+ : undefined,
370
1643
  viewport: json.viewport ? { ...json.viewport, pos: p0 } : undefined,
371
1644
  lanes: json.lanes
372
1645
  ? Object.fromEntries(
@@ -380,11 +1653,12 @@ export function jsonToDoc(json: DragonJson): DslDocument {
380
1653
  : undefined,
381
1654
  groups: json.groups
382
1655
  ? Object.fromEntries(
383
- Object.entries(json.groups).map(([id, g]) => [id, { id, label: g.label, lanes: g.lanes, pos: p0 }]),
1656
+ Object.entries(json.groups).map(([id, g]) => [
1657
+ id,
1658
+ { id, label: g.label, lanes: g.lanes, pos: p0 },
1659
+ ]),
384
1660
  )
385
1661
  : undefined,
386
- // CAR-1693 Phase 1: diagram-level layout mode (auto|manual)、 未指定は undefined = auto default
387
- layout: json.layout,
388
1662
  pos: p0,
389
1663
  };
390
1664
  }
@@ -412,11 +1686,16 @@ export function jsonToDoc(json: DragonJson): DslDocument {
412
1686
  */
413
1687
  export function jsonToDiagram(
414
1688
  json: unknown,
415
- opts?: { partsCatalog?: Record<string, CdlDiagram> },
1689
+ // **`onNotice` も通す**。 記法経路だけに通知を付けていたため、 同じ型を受ける JSON / YAML
1690
+ // 経路では読めない値や捨てた矢印が利用者へ届かなかった (review 指摘)。 エディタの YAML タブは
1691
+ // ここを通る
1692
+ opts?: { partsCatalog?: Record<string, CdlDiagram>; onNotice?: CompileToCdlOpts["onNotice"] },
416
1693
  ): CdlDiagram {
417
1694
  const v = validateJson(json);
418
1695
  if (!v.ok) {
419
- const msg = v.errors.map((e) => ` ${e.path}: ${e.message}${e.hint ? ` (${e.hint})` : ""}`).join("\n");
1696
+ const msg = v.errors
1697
+ .map((e) => ` ${e.path}: ${e.message}${e.hint ? ` (${e.hint})` : ""}`)
1698
+ .join("\n");
420
1699
  throw new Error(`Dragon JSON DSL validation error:\n${msg}`);
421
1700
  }
422
1701
  const doc = jsonToDoc(v.data);