@cardenelabs/dragon 0.8.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,10 +18,28 @@
18
18
  * YAML `animation: [step: "..."]` ⇔ JSON `{animation: [{step: "...", duration: 1.4, focus: [...]}]}`
19
19
  */
20
20
 
21
- import { PRESET_TYPES } from "./v05/parser";
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";
22
31
  import type { CompileToCdlOpts } from "./compile";
23
32
  import type { CdlDiagram, NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
24
- import type { DslDocument, DslActor, DslStep, DslAnimate, DslPhase, DslState, 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";
25
43
  import { checkValueExpression, isValueName, valueNameIssue } from "./value-syntax";
26
44
  import { compileToCdl } from "./compile";
27
45
 
@@ -41,6 +59,15 @@ export interface DragonJson {
41
59
  * 組み立て側が知らせを出す。 そちらは `actors[].eyebrow` に書く。
42
60
  */
43
61
  eyebrow?: string;
62
+ /**
63
+ * 2 軸で仕分ける図 (`type: quadrant`) の軸の名前 (#1294)。 記法の `axes:` と同じ。
64
+ *
65
+ * 書かないと「小さい / 大きい」 のままになり、何を判断する図か読めない。 区画の名前
66
+ * (`右上` 等) は軸の名前から決まる。
67
+ *
68
+ * 他の図種には軸が無いため、書かれていたら組み立て側が知らせる。
69
+ */
70
+ axes?: JsonAxes;
44
71
  /** 登場人物 (必須): 文字列 or { name, kind, ... } object */
45
72
  actors: (string | JsonActor)[];
46
73
  /** flow step 配列 (必須): { from, to, label, ... } */
@@ -75,28 +102,29 @@ export interface DragonJson {
75
102
  labelMargin?: number;
76
103
  };
77
104
  /** lanes (optional): topology / swimlane preset で使う lane 宣言 */
78
- lanes?: Record<string, {
79
- x?: number;
80
- width?: number;
81
- label?: string;
82
- contain?: boolean;
83
- lifeline?: boolean;
84
- /**
85
- * canvas pivot (CAR-1693 Phase 1) DSL 表面 `pos: {x, y}` = auto layout offset。 未指定は
86
- * backward compat、 set 済は Phase 2 の applyPosOffset で lane 位置を shift する。
87
- */
88
- pos?: LayoutPos;
89
- }>;
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
+ >;
90
120
  /** groups (optional): topology preset で使う group 宣言 */
91
- groups?: Record<string, {
92
- label?: string;
93
- lanes: string[];
94
- }>;
95
- /**
96
- * canvas pivot (CAR-1693 Phase 1) diagram-level layout mode。 "auto" (default) は catalog 100+
97
- * backward compat、 "manual" は Phase 4 で drag → pos: 保存の完全 manual mode として使う予定。
98
- */
99
- layout?: LayoutMode;
121
+ groups?: Record<
122
+ string,
123
+ {
124
+ label?: string;
125
+ lanes: string[];
126
+ }
127
+ >;
100
128
  }
101
129
 
102
130
  export interface JsonActor {
@@ -127,8 +155,80 @@ export interface JsonActor {
127
155
  * LLM JSON DSL では nested 明示 = `{ "state": { "v": 50 } }` が natural、 human 側の
128
156
  * inline 拡散 pattern (`- arc1: { kind: arc-gauge, v: 50 }`) とは記述形式が分岐する
129
157
  * (spec § 2.3 分岐設計、 human = YAML 手書き最適 / LLM = JSON structured 最適)。
158
+ *
159
+ * 見本でない箱に書くと誤りとして返す (#1294)。 記法側は読めない項目名として行番号付きで
160
+ * 知らせるため、黙って捨てると入口によって扱いが変わる。
130
161
  */
131
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;
132
232
  }
133
233
 
134
234
  export interface JsonStep {
@@ -136,7 +236,12 @@ export interface JsonStep {
136
236
  to: string;
137
237
  label: string;
138
238
  sub?: string;
139
- tone?: Tone;
239
+ /**
240
+ * 矢印の色 (#1304)。 記法の `(成功)` と同じく別名 (`成功` / `neutral` 等) も受ける。
241
+ *
242
+ * `string & {}` は箱の `tone` と同じ idiom = 正規の色名を補完に出しつつ別名も通す。
243
+ */
244
+ tone?: Tone | (string & {});
140
245
  style?: EdgeStyle;
141
246
  guard?: string;
142
247
  cardinality?: string;
@@ -162,6 +267,13 @@ export interface JsonPhase {
162
267
  body?: string;
163
268
  /** badge label */
164
269
  badge?: string;
270
+ /**
271
+ * その段で左の起点から描くもの (#1312)。 記法の `draw: line` と同じ。
272
+ *
273
+ * 受ける語は `line` だけ (`DRAW_WORDS`)。 折れ線の図で、その段の間に線が左端から
274
+ * 右へ伸びる。
275
+ */
276
+ draw?: string;
165
277
  /**
166
278
  * 段の中で値を動かす (#1186)。 記法の `tween: name 100 -> 90` と同じ。
167
279
  *
@@ -187,16 +299,19 @@ export interface JsonDslError {
187
299
  }
188
300
 
189
301
  /**
190
- * CAR-1657 = 既存 NodeKind list (v05/parser.ts の NODE_KIND_VALID と揃える必要あり)。
191
- * 未知 kind 値は parts identifier 候補として partId に格納する経路の判定基準。
192
- * 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` (図種) と同じ扱いにする。
193
313
  */
194
- const VALID_KIND_SET: ReadonlySet<string> = new Set([
195
- "actor", "function", "storage", "event", "cdn", "service", "database",
196
- "cache", "queue", "api", "person", "entity", "state", "container", "card",
197
- "lambda", "kms", "secret", "alb", "ecs", "rds", "s3", "iam", "user", "browser",
198
- "contract", "eoa", "multisig", "proxy", "library", "interface",
199
- ]);
314
+ const VALID_KIND_SET: ReadonlySet<string> = NODE_KIND_VALID;
200
315
 
201
316
  /**
202
317
  * 受け付ける図種。 **記法側と同じ集合を使う** (`v05/parser.ts` の `PRESET_TYPES`)。
@@ -206,6 +321,520 @@ const VALID_KIND_SET: ReadonlySet<string> = new Set([
206
321
  */
207
322
  const VALID_PRESETS: readonly PresetType[] = [...PRESET_TYPES];
208
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
+ * 遠い名前は勧めない。 無関係な項目名を勧めると、書いた人がそちらへ直して二度手間になる。
783
+ */
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
+ }
837
+
209
838
  /**
210
839
  * shape validation。 layer 1 = 必須 field + 型 check、 layer 2 は compile 側の validation に委譲。
211
840
  * fail-fast ではなく全 error 収集して返す (LLM に一括で修正させるため)。
@@ -221,11 +850,125 @@ function validateLayoutPos(v: unknown, path: string, errors: JsonDslError[]): vo
221
850
  return;
222
851
  }
223
852
  const p = v as Record<string, unknown>;
224
- if (typeof p.x !== "number" || !Number.isFinite(p.x)) {
225
- 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;
226
877
  }
227
- if (typeof p.y !== "number" || !Number.isFinite(p.y)) {
228
- 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);
887
+ }
888
+ }
889
+
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;
902
+ }
903
+ checkUnknownKeys(v, "viewport", "$.viewport", errors);
904
+ 表で検査(v as Record<string, unknown>, "viewport", "$.viewport", "viewport", errors);
905
+ }
906
+
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;
932
+ }
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);
946
+ }
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)) {
953
+ errors.push({
954
+ path: "$.axes",
955
+ message: "axes must be a plain object like { x: { left, right }, y: { bottom, top } }",
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);
229
972
  }
230
973
  }
231
974
 
@@ -309,8 +1052,14 @@ class 写せない extends Error {
309
1052
  *
310
1053
  * 線を引くのは、 5 round にわたって「読む前に量を作れる経路」 を潰し続けた末に、 残りが
311
1054
  * 呼ぶ側の code の中に移ったため。 潰す対象が自分の外に出た時点で、 この関数の責務ではない。
1055
+ *
1056
+ * **test のために export する** (#1295)。 写しの性質 (深さ / 数の上限 / 読む順 / 値を返す
1057
+ * 関数の扱い) を確かめる検査は、以前は「検査が見ない項目」 に構造をぶら下げて
1058
+ * `validateDragonJson` 越しに見ていた。 知らない項目を誤りにしたことでその足場が無くなり、
1059
+ * かつ写しは検査より前に走るため、検査の結果からは写しの中身を取り出せない。
1060
+ * 検査の対象そのものを直接呼ぶ形にする。
312
1061
  */
313
- function 素のデータに写す(
1062
+ export function 素のデータに写す(
314
1063
  root: unknown,
315
1064
  ): { ok: true; value: unknown } | { ok: false; error: JsonDslError } {
316
1065
  const 写し済 = new WeakMap<object, unknown[] | Record<string, unknown>>();
@@ -338,11 +1087,7 @@ function 素のデータに写す(
338
1087
  };
339
1088
 
340
1089
  /** 入れ物だけ作る (ここでは降りない)。 新しく作った時だけ枠を返す */
341
- const 器を作る = (
342
- v: unknown,
343
- 深さ: number,
344
- path: string,
345
- ): { 値: unknown; 枠: 枠 | null } => {
1090
+ const 器を作る = (v: unknown, 深さ: number, path: string): { 値: unknown; 枠: 枠 | null } => {
346
1091
  項目数 += 1;
347
1092
  if (項目数 > 写しの最大の項目数) {
348
1093
  throw new 写せない(path, `項目が多すぎる (上限 ${写しの最大の項目数})`);
@@ -449,7 +1194,9 @@ function 素のデータに写す(
449
1194
  }
450
1195
  }
451
1196
 
452
- function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: false; errors: JsonDslError[] } {
1197
+ function validateJson(
1198
+ json: unknown,
1199
+ ): { ok: true; data: DragonJson } | { ok: false; errors: JsonDslError[] } {
453
1200
  const errors: JsonDslError[] = [];
454
1201
  // root の形は写しより先に見る = 形が違う入力には従来どおり `root must be a JSON object` を
455
1202
  // 返すため。 写した後に見ると、 root が配列の入力で中の getter が先に動き、 別の誤りに化ける
@@ -481,25 +1228,14 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
481
1228
  if (!写し.ok) return { ok: false, errors: [写し.error] };
482
1229
  const j = 写し.value as Record<string, unknown>;
483
1230
 
484
- if (typeof j.title !== "string" || j.title.length === 0) {
485
- errors.push({ path: "$.title", message: "title must be a non-empty string" });
486
- }
487
- // 図表の箱の上の小見出し (#1247)。 空文字は「書かなかった」 と同じ扱いにするため通す
488
- // (記法側の `eyebrow:` と揃える。 落とすのは `jsonToDoc`)
489
- if (j.eyebrow !== undefined && typeof j.eyebrow !== "string") {
490
- errors.push({ path: "$.eyebrow", message: "eyebrow must be a string if present" });
491
- }
492
- // CAR-1693 Phase 1: diagram-level layout mode の validation (未指定 = auto default で backward compat)
493
- if (j.layout !== undefined && j.layout !== "auto" && j.layout !== "manual") {
494
- errors.push({ path: "$.layout", message: 'layout must be "auto" or "manual" if present' });
495
- }
496
- if (typeof j.type !== "string" || !VALID_PRESETS.includes(j.type as PresetType)) {
497
- errors.push({
498
- path: "$.type",
499
- message: `type must be one of: ${VALID_PRESETS.join(", ")}`,
500
- hint: typeof j.type === "string" ? `got "${j.type}"` : undefined,
501
- });
502
- }
1231
+ // 知らない項目を先に見る (#1295)。 綴り違いは「書いた項目が効かない」 形で表に出るため、
1232
+ // 個々の型の誤りより先に伝える方が直しやすい
1233
+ checkUnknownKeys(j, "root", "$", errors);
1234
+
1235
+ // 値そのものの型は表が見る (#1304)。 図表の箱の上の小見出し (#1247) の空文字は
1236
+ // 「書かなかった」 と同じ扱いにするため通す (記法側の `eyebrow:` と揃える。 落とすのは `jsonToDoc`)
1237
+ 表で検査(j, "root", "$", "", errors);
1238
+
503
1239
  if (!Array.isArray(j.actors) || j.actors.length === 0) {
504
1240
  errors.push({ path: "$.actors", message: "actors must be a non-empty array" });
505
1241
  } else {
@@ -510,19 +1246,49 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
510
1246
  return;
511
1247
  }
512
1248
  const ao = a as Record<string, unknown>;
513
- if (typeof ao.name !== "string" || ao.name.length === 0) {
514
- 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
+ }
515
1270
  }
516
- // CAR-1657 (+ codex-review MAJOR fix) = kind の validation、 non-empty string 必須。
517
- // parts identifier or existing NodeKind のどちらかを想定、 空文字 or 非 string は reject。
518
- if (ao.kind !== undefined && (typeof ao.kind !== "string" || ao.kind.length === 0)) {
519
- 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
+ }
520
1282
  }
1283
+ validateActorNodes(ao.nodes, `$.actors[${i}].nodes`, errors);
521
1284
  // codex-review MAJOR fix = state override は plain object + 値は primitive (number / string / boolean) 限定、
522
1285
  // `{ v: {} }` 等 nested object や null が流入すると CdlState.initial に不正な型が入り compile 崩れる。
523
1286
  if (ao.state !== undefined) {
524
1287
  if (!ao.state || typeof ao.state !== "object" || Array.isArray(ao.state)) {
525
- 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
+ });
526
1292
  } else {
527
1293
  for (const [sk, sv] of Object.entries(ao.state as Record<string, unknown>)) {
528
1294
  const svType = typeof sv;
@@ -548,21 +1314,26 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
548
1314
  return;
549
1315
  }
550
1316
  const so = s as Record<string, unknown>;
551
- if (typeof so.from !== "string") errors.push({ path: `$.flow[${i}].from`, message: "step.from must be a string" });
552
- if (typeof so.to !== "string") errors.push({ path: `$.flow[${i}].to`, message: "step.to must be a string" });
553
- 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);
554
1320
  // CAR-1693 Phase 1: step DSL 表面 pos の validation
555
1321
  validateLayoutPos(so.pos, `$.flow[${i}].pos`, errors);
556
1322
  });
557
1323
  }
558
- // CAR-1693 Phase 1: lane DSL 表面 pos の validation
559
- if (j.lanes !== undefined && j.lanes && typeof j.lanes === "object" && !Array.isArray(j.lanes)) {
560
- for (const [laneId, lane] of Object.entries(j.lanes as Record<string, unknown>)) {
561
- if (lane && typeof lane === "object" && !Array.isArray(lane)) {
562
- validateLayoutPos((lane as Record<string, unknown>).pos, `$.lanes.${laneId}.pos`, errors);
563
- }
564
- }
565
- }
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
+ });
566
1337
  if (j.animation !== undefined) {
567
1338
  if (!Array.isArray(j.animation)) {
568
1339
  errors.push({ path: "$.animation", message: "animation must be an array if present" });
@@ -573,15 +1344,16 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
573
1344
  return;
574
1345
  }
575
1346
  const po = p as Record<string, unknown>;
576
- if (typeof po.step !== "string" || po.step.length === 0) {
577
- errors.push({ path: `$.animation[${i}].step`, message: "phase.step must be a non-empty string" });
578
- }
1347
+ checkUnknownKeys(po, "phase", `$.animation[${i}]`, errors);
1348
+ // 値そのものの型は表が見る (#1304)
1349
+ 表で検査(po, "phase", `$.animation[${i}]`, "phase", errors);
579
1350
  validatePhaseMotion(po, i, errors);
580
1351
  });
581
1352
  }
582
1353
  }
583
1354
  validateStates(j.states, errors);
584
1355
  validateValues(j.values, errors);
1356
+ validateAxes(j.axes, errors);
585
1357
  if (errors.length > 0) return { ok: false, errors };
586
1358
  return { ok: true, data: j as unknown as DragonJson };
587
1359
  }
@@ -592,11 +1364,7 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
592
1364
  * 状態名の記法は `states:` と同じ判定を使う。 参照先はこの JSON の `states` だけでは決めない。
593
1365
  * 見本や preset が持つ状態を動かす指定もあるためで、記法側と同じく compile 後の図で解決する。
594
1366
  */
595
- function validatePhaseMotion(
596
- po: Record<string, unknown>,
597
- i: number,
598
- errors: JsonDslError[],
599
- ): void {
1367
+ function validatePhaseMotion(po: Record<string, unknown>, i: number, errors: JsonDslError[]): void {
600
1368
  if (po.tween !== undefined) {
601
1369
  if (!po.tween || typeof po.tween !== "object" || Array.isArray(po.tween)) {
602
1370
  errors.push({
@@ -615,7 +1383,11 @@ function validatePhaseMotion(
615
1383
  // 段の途中が壊れる (記法側も数だけを受ける)
616
1384
  for (const v of range) {
617
1385
  if (typeof v !== "number" || !Number.isFinite(v)) {
618
- errors.push({ path, message: "tween value must be finite numbers", hint: `got ${typeof v}` });
1386
+ errors.push({
1387
+ path,
1388
+ message: "tween value must be finite numbers",
1389
+ hint: `got ${typeof v}`,
1390
+ });
619
1391
  break;
620
1392
  }
621
1393
  }
@@ -653,7 +1425,10 @@ function validatePhaseMotion(
653
1425
  function validateStates(v: unknown, errors: JsonDslError[]): void {
654
1426
  if (v === undefined) return;
655
1427
  if (!v || typeof v !== "object" || Array.isArray(v)) {
656
- errors.push({ path: "$.states", message: "states must be a plain object of name -> initial value" });
1428
+ errors.push({
1429
+ path: "$.states",
1430
+ message: "states must be a plain object of name -> initial value",
1431
+ });
657
1432
  return;
658
1433
  }
659
1434
  for (const [name, initial] of Object.entries(v as Record<string, unknown>)) {
@@ -684,7 +1459,10 @@ function validateStates(v: unknown, errors: JsonDslError[]): void {
684
1459
  function validateValues(v: unknown, errors: JsonDslError[]): void {
685
1460
  if (v === undefined) return;
686
1461
  if (!v || typeof v !== "object" || Array.isArray(v)) {
687
- errors.push({ path: "$.values", message: "values must be a plain object of name -> expression" });
1462
+ errors.push({
1463
+ path: "$.values",
1464
+ message: "values must be a plain object of name -> expression",
1465
+ });
688
1466
  return;
689
1467
  }
690
1468
  for (const [name, expression] of Object.entries(v as Record<string, unknown>)) {
@@ -732,9 +1510,12 @@ export function jsonToDoc(json: DragonJson): DslDocument {
732
1510
  // CAR-1657 = kind が既存 NodeKind に無い値なら parts identifier 候補、 partId に格納
733
1511
  const kindStr = (a.kind ?? "actor") as string;
734
1512
  const isPart = kindStr !== "actor" && !VALID_KIND_SET.has(kindStr);
1513
+ // 色は記法と同じ振り分けを通す (#1294)。 `#` で始まれば色番号、それ以外は色の名前。
1514
+ // 別々に書くと、同じ値が入口によって色番号にも色名にもなる
1515
+ const 色 = a.color !== undefined ? splitColorValue(a.color) : {};
735
1516
  return {
736
1517
  name: a.name,
737
- kind: isPart ? "actor" as NodeKind : (a.kind ?? "actor") as NodeKind,
1518
+ kind: isPart ? "actor" : resolveNodeKind(kindStr),
738
1519
  // parts 候補は `kind` を `actor` に倒して `partId` へ退避するため、 名札に載せる種類としては
739
1520
  // 「書かなかった」 と同じ扱いにする (#1058)
740
1521
  kindWritten: a.kind !== undefined && !isPart,
@@ -746,6 +1527,37 @@ export function jsonToDoc(json: DragonJson): DslDocument {
746
1527
  stack: a.stack,
747
1528
  initial: a.initial,
748
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,
749
1561
  partId: isPart ? kindStr : undefined,
750
1562
  stateOverride: isPart ? a.state : undefined,
751
1563
  // CAR-1693 Phase 1: DSL 表面 pos → 内部 AST layoutPos の 2 層 mapping (naming collision 回避)
@@ -759,7 +1571,9 @@ export function jsonToDoc(json: DragonJson): DslDocument {
759
1571
  to: s.to,
760
1572
  label: s.label,
761
1573
  sub: s.sub,
762
- tone: s.tone,
1574
+ // 箱と同じ読み替えを通す (#1304)。 通さないと `tone: "成功"` が色名として解決されないまま
1575
+ // 図に届き、同じ値が箱では色になり矢印では色にならない
1576
+ tone: resolveTone(s.tone),
763
1577
  style: s.style,
764
1578
  guard: s.guard,
765
1579
  cardinality: s.cardinality,
@@ -783,11 +1597,18 @@ export function jsonToDoc(json: DragonJson): DslDocument {
783
1597
  highlight: p.focus,
784
1598
  body: p.body,
785
1599
  badge: p.badge,
1600
+ // 書いた段だけが欄を持つ。 空文字を置くと「書いた」 と「書いていない」 が同じ形になる
1601
+ ...(p.draw !== undefined ? { draw: p.draw, drawPos: p0 } : {}),
786
1602
  // 段の中で動かす分 (#1186)。 記法側の `tweens` / `sets` と同じ形に写す。
787
1603
  // 空の配列を置かないのは、記法側が「無ければ field ごと持たない」 形だから
788
1604
  ...(p.tween && Object.keys(p.tween).length > 0
789
1605
  ? {
790
- tweens: Object.entries(p.tween).map(([state, [from, to]]) => ({ state, from, to, pos: p0 })),
1606
+ tweens: Object.entries(p.tween).map(([state, [from, to]]) => ({
1607
+ state,
1608
+ from,
1609
+ to,
1610
+ pos: p0,
1611
+ })),
791
1612
  }
792
1613
  : {}),
793
1614
  ...(p.set && Object.keys(p.set).length > 0
@@ -806,6 +1627,11 @@ export function jsonToDoc(json: DragonJson): DslDocument {
806
1627
  ...(整えた小見出し(json.eyebrow) !== undefined
807
1628
  ? { eyebrow: 整えた小見出し(json.eyebrow), eyebrowPos: p0 }
808
1629
  : {}),
1630
+ // 2 軸で仕分ける図の軸の名前 (#1294)。 中身の無い形は「書かなかった」 と同じにする =
1631
+ // 空の軸を渡すと、書いていない側の名前が空文字で描かれる (記法側と同じ扱い)
1632
+ ...(json.axes && (json.axes.x !== undefined || json.axes.y !== undefined)
1633
+ ? { axes: json.axes, axesPos: p0 }
1634
+ : {}),
809
1635
  actors,
810
1636
  flow,
811
1637
  animate,
@@ -827,11 +1653,12 @@ export function jsonToDoc(json: DragonJson): DslDocument {
827
1653
  : undefined,
828
1654
  groups: json.groups
829
1655
  ? Object.fromEntries(
830
- 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
+ ]),
831
1660
  )
832
1661
  : undefined,
833
- // CAR-1693 Phase 1: diagram-level layout mode (auto|manual)、 未指定は undefined = auto default
834
- layout: json.layout,
835
1662
  pos: p0,
836
1663
  };
837
1664
  }
@@ -866,7 +1693,9 @@ export function jsonToDiagram(
866
1693
  ): CdlDiagram {
867
1694
  const v = validateJson(json);
868
1695
  if (!v.ok) {
869
- 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");
870
1699
  throw new Error(`Dragon JSON DSL validation error:\n${msg}`);
871
1700
  }
872
1701
  const doc = jsonToDoc(v.data);