@cardenelabs/dragon 0.7.0 → 0.8.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.
package/src/v05/parser.ts CHANGED
@@ -45,13 +45,22 @@ import type { NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
45
45
  import { TONES, NODE_KINDS } from "@cardenelabs/cdl";
46
46
  import { TONE_ALIAS } from "../keywords";
47
47
  import { parseRelativePos, orderByDependency } from "../relative-pos";
48
+ import {
49
+ checkValueExpression,
50
+ isTriggerBody,
51
+ isValueName,
52
+ parseValueTriggerBody,
53
+ valueNameIssue,
54
+ } from "../value-syntax";
48
55
  import type {
56
+ DslAxes,
49
57
  DslDocument,
50
58
  DslActor,
51
59
  DslActorNodeOverride,
52
60
  DslStep,
53
61
  DslAnimate,
54
62
  DslState,
63
+ DslValue,
55
64
  DslPhase,
56
65
  DslTween,
57
66
  DslSet,
@@ -66,6 +75,44 @@ export type V05ParseResult =
66
75
  | { ok: true; doc: DslDocument }
67
76
  | { ok: false; errors: DslError[] };
68
77
 
78
+ /**
79
+ * 記法が受ける top-level の項目 (#1190)。
80
+ *
81
+ * 読めない行の案内と、記法一覧が全て載せているかの検査が、どちらもここを見る。 一覧に手で
82
+ * 書くと、項目を足した時に案内か一覧のどちらかが取り残される (実際に `states` / `values` が
83
+ * 一覧に 1 件も無い状態で放置されていた)。
84
+ */
85
+ export const TOP_LEVEL_KEYS = [
86
+ "title", "type", "actors", "flow", "states", "values", "animation", "viewport", "lanes", "groups",
87
+ // 図全体を 1 箱にする図種で、 その箱の上に出す小見出し (#1247)
88
+ "eyebrow",
89
+ // 2 軸で仕分ける図の軸の名前 (#1251)
90
+ "axes",
91
+ ] as const;
92
+
93
+ /**
94
+ * `lanes:` / `groups:` の 1 行を読む形 (#1241)。
95
+ *
96
+ * **id は英数字と下線に限らない**。 組み立て側は登場人物の名前から縦列 id を作るため、
97
+ * hyphen と日本語が入る (実測 = `type: state` で `lane-idle` / `lane-待機`、
98
+ * `type: swimlane` で `lane-sign-up`)。 英数字と下線だけを受けていた間、
99
+ * **自動で作られた縦列の幅や見出しを書き直す手段が無かった**。
100
+ *
101
+ * 受けるのは **組み立て側が作りうる字だけ** に絞る。 字と数と下線と hyphen。
102
+ *
103
+ * 「読み取りを壊す字以外は何でも」 にすると、`lane-idle,` のような書き間違いが
104
+ * **別の縦列として通り**、書いた幅が黙って効かなくなる (Round 1 の指摘、実測)。
105
+ *
106
+ * **非 ASCII をまとめて許すのも広すぎる** (Round 2 の指摘)。 全角の読点や感嘆符、絵文字まで
107
+ * 通ってしまう (実測 = `lane-idle、` / `lane-idle!` / `lane-idle🙂` が受かった)。
108
+ * 字 (`\p{L}`) と数 (`\p{N}`) だけを許せば、日本語の縦列 id は通しつつ句読点は外せる。
109
+ */
110
+ const LANE_ID_ENTRY = /^([\p{L}\p{N}_-]+)\s*:\s*\{([^}]*)\}\s*$/u;
111
+
112
+ function isTopLevelKey(key: string): key is (typeof TOP_LEVEL_KEYS)[number] {
113
+ return (TOP_LEVEL_KEYS as readonly string[]).includes(key);
114
+ }
115
+
69
116
  /** 受け付ける図種。 記法一覧はここを見る。 */
70
117
  export const PRESET_TYPES: ReadonlySet<PresetType> = new Set([
71
118
  "sequence",
@@ -78,6 +125,12 @@ export const PRESET_TYPES: ReadonlySet<PresetType> = new Set([
78
125
  "gantt",
79
126
  "class",
80
127
  "pie",
128
+ "bar",
129
+ "line",
130
+ "funnel",
131
+ "tree",
132
+ "journey",
133
+ "quadrant",
81
134
  "c4",
82
135
  "mind",
83
136
  ]);
@@ -157,9 +210,14 @@ export function parseTextDslV05(src: string): V05ParseResult {
157
210
 
158
211
  let title: string | null = null;
159
212
  let type: PresetType | null = null;
213
+ let eyebrow: string | null = null;
214
+ let eyebrowLine = 0;
215
+ let axes: DslAxes | undefined = undefined;
216
+ let axesLine = 0;
160
217
  let actors: DslActor[] = [];
161
218
  const flow: DslStep[] = [];
162
219
  let animate: DslAnimate | undefined = undefined;
220
+ const values: DslValue[] = [];
163
221
  let viewport: DslViewport | undefined = undefined;
164
222
  let lanesMap: Record<string, DslLane> | undefined = undefined;
165
223
  let groupsMap: Record<string, DslGroup> | undefined = undefined;
@@ -172,11 +230,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
172
230
  continue;
173
231
  }
174
232
  const head = matchTopHeader(line.trimmed);
175
- if (!head) {
233
+ if (!head || !isTopLevelKey(head.key)) {
176
234
  errors.push({
177
235
  line: line.no,
178
236
  message: `unknown top-level key: "${line.trimmed}"`,
179
- hint: "expected one of: title, type, actors, flow, states, animation, viewport, lanes, groups",
237
+ hint: `expected one of: ${TOP_LEVEL_KEYS.join(", ")}`,
180
238
  });
181
239
  i += 1;
182
240
  continue;
@@ -189,6 +247,15 @@ export function parseTextDslV05(src: string): V05ParseResult {
189
247
  i += 1;
190
248
  continue;
191
249
  }
250
+ if (head.key === "eyebrow") {
251
+ // 空で書いた形 (`eyebrow:`) は「書かなかった」 と同じにする。 空文字を残すと
252
+ // 描画側が中身のない帯を出す
253
+ const v = (head.value ?? "").trim();
254
+ eyebrow = v.length > 0 ? v : null;
255
+ eyebrowLine = line.no;
256
+ i += 1;
257
+ continue;
258
+ }
192
259
  if (head.key === "type") {
193
260
  const v = (head.value ?? "").trim().toLowerCase();
194
261
  if (!PRESET_TYPES.has(v as PresetType)) {
@@ -265,6 +332,36 @@ export function parseTextDslV05(src: string): V05ParseResult {
265
332
  i = next;
266
333
  continue;
267
334
  }
335
+ if (head.key === "values") {
336
+ // 1 行に詰める形 (`values: { a: "..." }` / `values: a: "..."`) は受けない。 式に `,` が
337
+ // 入る (`min({a}, {b})`) ため、 `states` が使う素朴な `,` 分割では式が壊れる。
338
+ //
339
+ // **`{` で始まるかを見てはいけない** (#1169)。 `values: a: "{b} + 1"` は `{` で始まらない
340
+ // ため判定を通り抜け、 その後 `collectIndentedRaw` が次行以降しか見ないので **値が
341
+ // 1 件も読まれずに黙って消える**。 書き間違いを黙って捨てないという `collectIndentedRaw`
342
+ // を自前で持った理由と矛盾する。
343
+ //
344
+ // 同じ行に何か書いてあれば形を問わず弾く = 1 行形は全て受けないという規則そのもの。
345
+ const inline = head.value?.trim();
346
+ if (inline) {
347
+ errors.push({
348
+ line: line.no,
349
+ message: "values は 1 行にまとめて書けない",
350
+ hint: '式に `,` が入るため。 次の行から字下げして `waiting: "{inflow} - {done}"` の形で並べる',
351
+ });
352
+ i += 1;
353
+ continue;
354
+ }
355
+ // `collectIndentedList` は `:` を含まない行を黙って捨てる。 捨てられると
356
+ // 書き間違えた行が「書かなかった」 と同じになり、 値が 1 つ消えたことに気付けない
357
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
358
+ for (const it of items) {
359
+ const v = parseValueEntry(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
360
+ if (v) values.push(v);
361
+ }
362
+ i = next;
363
+ continue;
364
+ }
268
365
  if (head.key === "animation") {
269
366
  // animation: は step を list で並べる
270
367
  const { items: stepBlocks, next } = collectAnimationSteps(lines, i + 1, line.indent);
@@ -316,12 +413,51 @@ export function parseTextDslV05(src: string): V05ParseResult {
316
413
  i = next;
317
414
  continue;
318
415
  }
416
+ if (head.key === "axes") {
417
+ // axes:\n x: { left: "...", right: "..." }\n y: { bottom: "...", top: "..." }
418
+ //
419
+ // **1 行にまとめて書く形は受けない** (Round 1 の指摘)。 受けないなら黙って捨てず、
420
+ // その場で伝える = 捨てると軸を書いたつもりの本文が既定のまま描かれる (`values:` と同じ)
421
+ if (head.value !== null && head.value.trim() !== "") {
422
+ errors.push({
423
+ line: line.no,
424
+ message: "axes は 1 行にまとめて書けない",
425
+ hint: '次の行から字下げして `x: { left: "...", right: "..." }` の形で並べる',
426
+ });
427
+ i += 1;
428
+ continue;
429
+ }
430
+ // **`collectIndentedList` を使わない** (Round 1 の指摘)。 あちらは `:` を含まない行を
431
+ // 黙って捨てるため、書き間違えた行が「書かなかった」 と同じになる
432
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
433
+ axesLine = line.no;
434
+ const 組み立て: DslAxes = {};
435
+ for (const it of items) {
436
+ const m = it.trimmed.match(/^(x|y)\s*:\s*\{([^}]*)\}\s*$/);
437
+ if (!m) {
438
+ errors.push({
439
+ line: it.no,
440
+ message: `invalid axes entry: "${it.trimmed}"`,
441
+ hint: 'use `x: { left: "...", right: "..." }` or `y: { bottom: "...", top: "..." }`',
442
+ });
443
+ continue;
444
+ }
445
+ const opts = parseInlineMapping(m[2] ?? "");
446
+ if (m[1] === "x") 組み立て.x = { left: opts.left, right: opts.right };
447
+ else 組み立て.y = { bottom: opts.bottom, top: opts.top };
448
+ }
449
+ // 1 本も読めなかった形は「書かなかった」 と同じにする。 空の軸を渡すと、
450
+ // 書いていない側の名前が空文字で描かれる
451
+ axes = 組み立て.x !== undefined || 組み立て.y !== undefined ? 組み立て : undefined;
452
+ i = next;
453
+ continue;
454
+ }
319
455
  if (head.key === "lanes") {
320
456
  // lanes:\n l1: { x: 0, width: 320, label: "..." }\n l2: { ... }
321
457
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
322
458
  lanesMap = {};
323
459
  for (const it of items) {
324
- const m = it.trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*\{([^}]*)\}\s*$/);
460
+ const m = it.trimmed.match(LANE_ID_ENTRY);
325
461
  if (m) {
326
462
  const id = m[1]!;
327
463
  const opts = parseInlineMapping(m[2]!);
@@ -350,7 +486,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
350
486
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
351
487
  groupsMap = {};
352
488
  for (const it of items) {
353
- const m = it.trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*\{([^}]*)\}\s*$/);
489
+ const m = it.trimmed.match(LANE_ID_ENTRY);
354
490
  if (m) {
355
491
  const id = m[1]!;
356
492
  const opts = parseInlineMapping(m[2]!);
@@ -389,9 +525,12 @@ export function parseTextDslV05(src: string): V05ParseResult {
389
525
  doc: {
390
526
  title: title!,
391
527
  type: type!,
528
+ ...(eyebrow !== null ? { eyebrow, eyebrowPos: { line: eyebrowLine } } : {}),
529
+ ...(axes !== undefined ? { axes, axesPos: { line: axesLine } } : {}),
392
530
  actors,
393
531
  flow,
394
532
  animate,
533
+ ...(values.length > 0 ? { values } : {}),
395
534
  viewport,
396
535
  lanes: lanesMap,
397
536
  groups: groupsMap,
@@ -803,6 +942,13 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
803
942
  let touchedState = false;
804
943
  /** 縦に並べて書かれた倍率。 同じ名前が 2 度出たら後の値で上書きする */
805
944
  const scaleWritten = new Map<string, string>();
945
+ /**
946
+ * 縦に並べて書かれた体験の道筋の欄 (#1251)。
947
+ *
948
+ * パーツでは状態の上書きとして意味を持つため、どちらに入れるかは block を読み終わってから
949
+ * 決める。 `kind:` の行が後ろに書かれることもあり、読んだ時点ではパーツか分からない。
950
+ */
951
+ const 図種ごとの欄 = new Map<string, string>();
806
952
  // パーツでなければどこにも入らない項目。 パーツかどうかは block を読み終わるまで決まらない
807
953
  const unknownKeys: Array<{ key: string; line: number }> = [];
808
954
 
@@ -916,6 +1062,15 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
916
1062
  });
917
1063
  break;
918
1064
  }
1065
+ case "touchpoint":
1066
+ case "opportunity":
1067
+ case "owner":
1068
+ case "end":
1069
+ // **どちらに入れるかは block を読み終わるまで決まらない** (#1251 Round 1 の指摘)。
1070
+ // パーツかどうかは `kind:` の行で決まり、それが後ろに書かれることもある。
1071
+ // 倍率 (`scaleWritten`) と読めない項目名 (`unknownKeys`) が同じ理由で後回しにしている
1072
+ 図種ごとの欄.set(key, stripQuotes(raw));
1073
+ break;
919
1074
  case "lane":
920
1075
  out.lane = stripQuotes(raw);
921
1076
  break;
@@ -937,6 +1092,18 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
937
1092
  out.scale = s.scale;
938
1093
  out.scaleKeys = [...new Set([...(actor.scaleKeys ?? []), ...s.keys])];
939
1094
  }
1095
+ // 体験の道筋の欄は、パーツなら状態の上書き、そうでなければ道筋の欄として入れる
1096
+ for (const [key, v] of 図種ごとの欄) {
1097
+ if (out.partId !== undefined) {
1098
+ state[key] = coerceStateValue(v);
1099
+ touchedState = true;
1100
+ continue;
1101
+ }
1102
+ if (key === "touchpoint") out.touchpoint = v;
1103
+ else if (key === "opportunity") out.opportunity = v;
1104
+ else if (key === "owner") out.owner = v;
1105
+ else out.end = v;
1106
+ }
940
1107
  // 状態も倍率も parts でだけ意味を持つ。 パーツなら知らせずに返す
941
1108
  if (out.partId !== undefined) {
942
1109
  if (touchedState) out.stateOverride = state;
@@ -969,6 +1136,10 @@ export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
969
1136
  "大きさ", "size",
970
1137
  "倍率", "scale",
971
1138
  "lane", "stack",
1139
+ // 体験の道筋の欄 (#1251)
1140
+ "touchpoint", "opportunity",
1141
+ // 工程の並びの欄 (#1251)
1142
+ "owner", "end",
972
1143
  ]);
973
1144
 
974
1145
  /**
@@ -1092,6 +1263,9 @@ const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
1092
1263
  "initial",
1093
1264
  "final",
1094
1265
  "state",
1266
+ // 体験の道筋の欄 (`touchpoint` / `opportunity`) はここに載せない (#1251 Round 1 の指摘)。
1267
+ // 載せるとパーツで同じ名前の状態を書いた時に横取りされる = 既に動いている見本が静かに変わる。
1268
+ // パーツでない箱でだけ道筋の欄として読む (`parseActor` / `applyContinuationLines` が分岐する)
1095
1269
  // canvas pivot 新 spec = 絶対座標 4 field (dragon canvas pivot spec §layout-role-conversion)
1096
1270
  "posX",
1097
1271
  "posY",
@@ -1220,9 +1394,13 @@ function reportScaleOnNonPart(
1220
1394
  * 並べた形) とは別に持つ = 中括弧の形は位置や大きさを未対応にしてあり、 同じ集合にすると
1221
1395
  * 「知らせない」 側がずれる。
1222
1396
  */
1223
- const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
1397
+ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
1224
1398
  "kind", "subtitle", "eyebrow", "value", "rows", "lane", "stack",
1225
1399
  "initial", "final", "tone", "nodes",
1400
+ // 体験の道筋の欄 (#1251)。 他の図種では組み立て側が知らせる
1401
+ "touchpoint", "opportunity",
1402
+ // 工程の並びの欄 (#1251)
1403
+ "owner", "end",
1226
1404
  "posX", "posY", "posW", "posH",
1227
1405
  // 倍率は別経路 (`reportScaleOnNonPart`) が知らせる。 ここでも読める扱いにしないと
1228
1406
  // 同じ名前で 2 度知らせることになる
@@ -1233,6 +1411,25 @@ const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
1233
1411
  // している経路が予約語で残る (Round 1 review の指摘、 実測で確認)。 パーツ側は `isPart` の
1234
1412
  // 早期 return が先に効くのでここに載せる必要が無い
1235
1413
 
1414
+ /**
1415
+ * 矢印の中括弧に書ける欄と、その読み方 (#1275)。
1416
+ *
1417
+ * **parser がこの表を回して読む**。 欄ごとに `opts.xxx` を並べる形だと、README や検査が
1418
+ * 持つ一覧が実装と drift する = 欄を足しても誰も気付けない。 表を唯一の出どころにして、
1419
+ * `FLOW_INLINE_KEYS` から一覧を導けるようにする。
1420
+ */
1421
+ const FLOW_INLINE_READERS = {
1422
+ sub: (v: string | undefined) => v,
1423
+ guard: (v: string | undefined) => v,
1424
+ cardinality: (v: string | undefined) => v,
1425
+ labelOffsetX: (v: string | undefined) => numberOrUndef(v),
1426
+ labelOffsetY: (v: string | undefined) => numberOrUndef(v),
1427
+ overlay: (v: string | undefined) => boolOrUndef(v),
1428
+ } as const;
1429
+
1430
+ /** 矢印の中括弧に書ける欄の名前。 README の一覧と突き合わせる (#1275) */
1431
+ export const FLOW_INLINE_KEYS = Object.keys(FLOW_INLINE_READERS) as readonly (keyof typeof FLOW_INLINE_READERS)[];
1432
+
1236
1433
  /**
1237
1434
  * 中括弧に書かれた読めない項目名を知らせる (#1090)。
1238
1435
  *
@@ -1304,6 +1501,12 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1304
1501
  kindWritten: kindRaw !== "" && !isPart,
1305
1502
  subtitle: opts.subtitle,
1306
1503
  eyebrow: opts.eyebrow,
1504
+ // パーツでは状態の上書きとして意味を持つため、道筋の欄として横取りしない (#1251)
1505
+ touchpoint: isPart ? undefined : opts.touchpoint,
1506
+ opportunity: isPart ? undefined : opts.opportunity,
1507
+ // 見本では状態の上書きとして意味を持つため横取りしない (#1251)
1508
+ owner: isPart ? undefined : opts.owner,
1509
+ end: isPart ? undefined : opts.end,
1307
1510
  value: opts.value,
1308
1511
  rows: opts.rows
1309
1512
  ? opts.rows
@@ -1381,7 +1584,7 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1381
1584
  // 1. `Client -> API` ... label / option なし
1382
1585
  // 2. `Client -> API: "deposit"` ... label
1383
1586
  // 3. `Client -> API: "deposit" (success)` ... label + tone tuple
1384
- // 4. `Client -> API: "deposit" { sub: "...", guard: "...", cardinality: "1:N", labelOffsetY: -8 }` ... inline option
1587
+ // 4. `Client -> API: "deposit" { sub: "...", guard: "...", cardinality: "1:N", labelOffsetY: -8, overlay: true }` ... inline option
1385
1588
  // 5. `Client -> API: "deposit" (success) { guard: "..." }` ... 両方
1386
1589
  const raw = line.trimmed;
1387
1590
  const arrowIdx = raw.indexOf("->");
@@ -1391,22 +1594,21 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1391
1594
  let label = "";
1392
1595
  let tone: Tone | undefined;
1393
1596
  let style: EdgeStyle | undefined;
1394
- let sub: string | undefined;
1395
- let guard: string | undefined;
1396
- let cardinality: string | undefined;
1397
- let labelOffsetX: number | undefined;
1398
- let labelOffsetY: number | undefined;
1597
+ // 欄は `FLOW_INLINE_READERS` の表から読む。 個別に並べると一覧が実装と drift する
1598
+ const 中括弧: Partial<Record<keyof typeof FLOW_INLINE_READERS, unknown>> = {};
1399
1599
  // inline option (`{ ... }`) を末尾から抽出
1400
1600
  const mapMatch = rest.match(/\s*\{([^}]*)\}\s*$/);
1401
1601
  if (mapMatch) {
1402
1602
  const opts = parseInlineMapping(mapMatch[1]!);
1403
- sub = opts.sub;
1404
- guard = opts.guard;
1405
- cardinality = opts.cardinality;
1406
- labelOffsetX = numberOrUndef(opts.labelOffsetX);
1407
- labelOffsetY = numberOrUndef(opts.labelOffsetY);
1603
+ for (const k of FLOW_INLINE_KEYS) 中括弧[k] = FLOW_INLINE_READERS[k](opts[k]);
1408
1604
  rest = rest.slice(0, mapMatch.index ?? 0).trim();
1409
1605
  }
1606
+ const sub = 中括弧.sub as string | undefined;
1607
+ const guard = 中括弧.guard as string | undefined;
1608
+ const cardinality = 中括弧.cardinality as string | undefined;
1609
+ const labelOffsetX = 中括弧.labelOffsetX as number | undefined;
1610
+ const labelOffsetY = 中括弧.labelOffsetY as number | undefined;
1611
+ const overlay = 中括弧.overlay as boolean | undefined;
1410
1612
  // 色と線種を末尾から取る。 括弧 (`(成功)`) と空白区切り (`成功`) の両方を受け付ける。
1411
1613
  //
1412
1614
  // 括弧は従来の書き方で、 catalog が使っている。 空白区切りは登場人物と揃えた形。
@@ -1453,15 +1655,17 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1453
1655
  cardinality,
1454
1656
  labelOffsetX,
1455
1657
  labelOffsetY,
1658
+ overlay,
1456
1659
  pos: { line: line.no },
1457
1660
  };
1458
1661
  }
1459
1662
 
1460
1663
  function parseStateEntry(text: string, lineNo: number): DslState | null {
1461
1664
  // `client_bal: 100` / `status: "idle"`
1462
- const m = text.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
1665
+ const m = text.match(/^([^:]+?)\s*:\s*(.+)$/);
1463
1666
  if (!m) return null;
1464
- const name = m[1] ?? "";
1667
+ const name = (m[1] ?? "").trim();
1668
+ if (!isValueName(name)) return null;
1465
1669
  const raw = (m[2] ?? "").trim();
1466
1670
  const stripped = stripQuotes(raw);
1467
1671
  const asNum = Number(stripped);
@@ -1469,6 +1673,77 @@ function parseStateEntry(text: string, lineNo: number): DslState | null {
1469
1673
  return { name, initial, pos: { line: lineNo } };
1470
1674
  }
1471
1675
 
1676
+ /**
1677
+ * 字下げした行を 1 行も落とさずに集める。
1678
+ *
1679
+ * `collectIndentedList` は形が合わない行を黙って捨てるが、 `values` では捨てずに
1680
+ * 読み手 (`parseValueEntry`) へ渡して書き間違いとして報告させる。
1681
+ */
1682
+ function collectIndentedRaw(
1683
+ lines: Line[],
1684
+ start: number,
1685
+ parentIndent: number,
1686
+ ): { items: Line[]; next: number } {
1687
+ const items: Line[] = [];
1688
+ let i = start;
1689
+ while (i < lines.length) {
1690
+ const ln = lines[i];
1691
+ if (!ln || !ln.trimmed || ln.trimmed.startsWith("#")) {
1692
+ i += 1;
1693
+ continue;
1694
+ }
1695
+ if (ln.indent <= parentIndent) break;
1696
+ items.push(ln);
1697
+ i += 1;
1698
+ }
1699
+ return { items, next: i };
1700
+ }
1701
+
1702
+ /**
1703
+ * `waiting: "{inflow} - {done}"` を 1 件の値として読む。
1704
+ *
1705
+ * 名前と式の判定は `value-syntax.ts` が持つ (#1181)。 JSON 経路も同じ判定を使うため、
1706
+ * ここでは行番号を付けて報告する形にだけ責任を持つ。
1707
+ */
1708
+ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslValue | null {
1709
+ const m = text.match(/^([^:]+?)\s*:\s*(.+)$/);
1710
+ if (!m) {
1711
+ errors.push({
1712
+ line: lineNo,
1713
+ message: `invalid value entry: "${text}"`,
1714
+ hint: '`waiting: "{inflow} - {done}"` の形で書く',
1715
+ });
1716
+ return null;
1717
+ }
1718
+ const name = (m[1] ?? "").trim();
1719
+ if (!isValueName(name)) {
1720
+ errors.push({ line: lineNo, ...valueNameIssue(name) });
1721
+ return null;
1722
+ }
1723
+ const rest = (m[2] ?? "").trim();
1724
+ // きっかけ形 (`{ trigger: ..., to: ..., dur: ... }`) を先に見る。 式として読むと中括弧の中身が
1725
+ // 値の名前として検査され、 「trigger は名前に使えない」 のような直し方の伝わらない誤りになる
1726
+ if (isTriggerBody(rest)) {
1727
+ const { spec, issues } = parseValueTriggerBody(rest.slice(1, -1), name);
1728
+ if (spec === null) {
1729
+ for (const issue of issues) errors.push({ line: lineNo, ...issue });
1730
+ return null;
1731
+ }
1732
+ return { name, trigger: spec.trigger, to: spec.to, durationMs: spec.durationMs, pos: { line: lineNo } };
1733
+ }
1734
+ const expression = stripQuotes(rest);
1735
+ if (expression === "") {
1736
+ errors.push({ line: lineNo, message: `empty expression for "${name}"`, hint: '`"{a} + {b}"` のように式を書く' });
1737
+ return null;
1738
+ }
1739
+ const issues = checkValueExpression(expression, name);
1740
+ if (issues.length > 0) {
1741
+ for (const issue of issues) errors.push({ line: lineNo, ...issue });
1742
+ return null;
1743
+ }
1744
+ return { name, expression, pos: { line: lineNo } };
1745
+ }
1746
+
1472
1747
  function splitTopLevelCommas(s: string): string[] {
1473
1748
  // brace 内を考慮 ... 今回は単純 split (動作する範囲)
1474
1749
  return s.split(",").map((x) => x.trim()).filter(Boolean);
@@ -1605,12 +1880,26 @@ function parseFocusList(s: string): string[] {
1605
1880
  // quote 内の space / comma / arrow は保護し、 quote 外の comma でのみ split する。
1606
1881
  let body = s.trim();
1607
1882
  if (body.startsWith("[") && body.endsWith("]")) body = body.slice(1, -1);
1608
- const parts: string[] = [];
1883
+ // **引用部分を非引用部分と分けて覚えておく** (#1192)。 区間全体に quoted flag を
1884
+ // 付けるだけだと `Client "Aave v3"` まで 1 item になり、従来の空白区切りと混在できない。
1885
+ type FocusFragment = { text: string; quoted: boolean };
1886
+ const groups: FocusFragment[][] = [];
1887
+ let group: FocusFragment[] = [];
1609
1888
  let buf = "";
1610
1889
  let quote: string | null = null;
1890
+ const pushFragment = (quoted: boolean): void => {
1891
+ if (buf.trim()) group.push({ text: buf, quoted });
1892
+ buf = "";
1893
+ };
1894
+ const pushGroup = (): void => {
1895
+ pushFragment(false);
1896
+ if (group.length > 0) groups.push(group);
1897
+ group = [];
1898
+ };
1611
1899
  for (const ch of body) {
1612
1900
  if (quote) {
1613
1901
  if (ch === quote) {
1902
+ pushFragment(true);
1614
1903
  quote = null;
1615
1904
  continue;
1616
1905
  }
@@ -1618,36 +1907,43 @@ function parseFocusList(s: string): string[] {
1618
1907
  continue;
1619
1908
  }
1620
1909
  if (ch === "\"" || ch === "'") {
1621
- quote = ch;
1622
- continue;
1910
+ // item の途中にある引用符は名前の一部。 空白または区切りの直後だけ囲みを開始する。
1911
+ if (!buf || /\s$/.test(buf)) {
1912
+ pushFragment(false);
1913
+ quote = ch;
1914
+ continue;
1915
+ }
1623
1916
  }
1624
1917
  if (ch === ",") {
1625
- const t = buf.trim();
1626
- if (t) parts.push(t);
1627
- buf = "";
1918
+ pushGroup();
1628
1919
  continue;
1629
1920
  }
1630
1921
  buf += ch;
1631
1922
  }
1632
- const tail = buf.trim();
1633
- if (tail) parts.push(tail);
1634
- // "User -> API" のような quote 済 item は「1 item」 として parts に入る。
1923
+ pushFragment(quote !== null);
1924
+ if (group.length > 0) groups.push(group);
1925
+ // "User -> API" のような quote 済 item は「1 item」 として groups に入る。
1635
1926
  // quote 外 item は依然として space split (旧挙動、 「Client API」 が 2 item として解釈される互換維持)。
1636
1927
  const out: string[] = [];
1637
- for (const p of parts) {
1638
- if (/[-→][>]?/.test(p) && /\s/.test(p)) {
1639
- // arrow を含む item は「A -> B」 パターン、 分割せず 1 item として保持
1640
- out.push(p);
1928
+ for (const fragments of groups) {
1929
+ const whole = fragments.map(({ text }) => text).join("").trim();
1930
+ if (fragments.every(({ quoted }) => !quoted) && /[-→][>]?/.test(whole) && /\s/.test(whole)) {
1931
+ // arrow を含む非引用区間は「A -> B」パターン。 空白で分割しない。
1932
+ out.push(whole);
1641
1933
  continue;
1642
1934
  }
1643
- if (/\s/.test(p)) {
1644
- // space 含み + arrow なし = 旧挙動の「Client API」 → 2 item
1645
- for (const x of p.split(/\s+/)) {
1646
- if (x) out.push(x);
1935
+ for (const { text, quoted } of fragments) {
1936
+ const p = text.trim();
1937
+ if (!p) continue;
1938
+ // 引用符で囲んだ item は空白があっても切らず、非引用部分だけを従来どおり空白で切る。
1939
+ if (quoted) {
1940
+ out.push(p);
1941
+ } else {
1942
+ for (const x of p.split(/\s+/)) {
1943
+ if (x) out.push(x);
1944
+ }
1647
1945
  }
1648
- continue;
1649
1946
  }
1650
- out.push(p);
1651
1947
  }
1652
1948
  return out;
1653
1949
  }
@@ -1655,10 +1951,12 @@ function parseFocusList(s: string): string[] {
1655
1951
  function parseTweenLine(s: string, lineNo: number): DslTween | null {
1656
1952
  // `client_bal 100 -> 90` / `client_bal: 100 -> 90`
1657
1953
  const cleaned = s.replace(/^-\s*/, "").trim();
1658
- const m = cleaned.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*[:\s]\s*(-?\d+(?:\.\d+)?)\s*->\s*(-?\d+(?:\.\d+)?)$/);
1954
+ const m = cleaned.match(/^([^:\s]+)\s*[:\s]\s*(-?\d+(?:\.\d+)?)\s*->\s*(-?\d+(?:\.\d+)?)$/);
1659
1955
  if (!m) return null;
1956
+ const state = m[1] ?? "";
1957
+ if (!isValueName(state)) return null;
1660
1958
  return {
1661
- state: m[1] ?? "",
1959
+ state,
1662
1960
  from: parseFloat(m[2] ?? "0"),
1663
1961
  to: parseFloat(m[3] ?? "0"),
1664
1962
  pos: { line: lineNo },
@@ -1668,11 +1966,13 @@ function parseTweenLine(s: string, lineNo: number): DslTween | null {
1668
1966
  function parseSetLine(s: string, lineNo: number): DslSet | null {
1669
1967
  // `status: "loading"` / `status loading`
1670
1968
  const cleaned = s.replace(/^-\s*/, "").trim();
1671
- const m = cleaned.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*[:\s]\s*(.+)$/);
1969
+ const m = cleaned.match(/^([^:\s]+)\s*[:\s]\s*(.+)$/);
1672
1970
  if (!m) return null;
1971
+ const state = m[1] ?? "";
1972
+ if (!isValueName(state)) return null;
1673
1973
  const raw = (m[2] ?? "").trim();
1674
1974
  const stripped = stripQuotes(raw);
1675
1975
  const asNum = Number(stripped);
1676
1976
  const value: number | string = Number.isFinite(asNum) && stripped !== "" && !isNaN(asNum) ? asNum : stripped;
1677
- return { state: m[1] ?? "", value, pos: { line: lineNo } };
1977
+ return { state, value, pos: { line: lineNo } };
1678
1978
  }