@cardenelabs/dragon 0.7.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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Notation lint (author 向け修正システム)。
3
+ *
4
+ * cdl / dragon 記法で書かれた CdlDiagram を rule-based に検査し、
5
+ * 「もっと良い書き方」 を suggestion として返す純粋関数。 LLM 不要、 rule のみ。
6
+ *
7
+ * 検知システム (`check:cdl` / `check:dragon` / `check:kind`) は開発陣向けで
8
+ * 「実装バグ」 を検出するが、 本 notation-lint は **author 向け** で
9
+ * 「書き方の癖 / 冗長表現 / 未定義参照 / 空 payload」 等を検出する。
10
+ *
11
+ * 使い方:
12
+ * import { lintDiagram } from "@cardenelabs/dragon";
13
+ * const report = lintDiagram(diagram);
14
+ * // report.issues[] = LintIssue[]
15
+ * // report.fixed = LintIssue[] のうち自動修正で解消される件
16
+ * // report.autoFix(diagram) = 修正済 CdlDiagram
17
+ *
18
+ * CLI:
19
+ * pnpm dragon-lint apps/playground-spa/src/topics/catalog/presets.cdl.ts
20
+ */
21
+ import type { CdlDiagram, CdlNode } from "@cardenelabs/cdl";
22
+
23
+ export type LintSeverity = "warn" | "info";
24
+
25
+ export type LintIssue = {
26
+ /** rule 識別子 */
27
+ rule: string;
28
+ severity: LintSeverity;
29
+ /** 該当対象 (node id / edge id / diagram id) */
30
+ target: string;
31
+ /** 人間向けメッセージ */
32
+ message: string;
33
+ /** 修正案 (自動修正可能なら適用後の値、 手動修正必要なら null) */
34
+ suggestion?: string;
35
+ /** autoFix() が本 issue を自動解消できるか */
36
+ autoFixable: boolean;
37
+ };
38
+
39
+ export type LintReport = {
40
+ diagramId: string;
41
+ issues: LintIssue[];
42
+ /** autoFix() が実際に解消できる issue の数 */
43
+ autoFixableCount: number;
44
+ };
45
+
46
+ const REDUNDANT_TOPIC_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [
47
+ { pattern: /\bpreset\s*\(/i, hint: "「〜 preset (詳細)」 は実装表現、 「〜 を示す図」 のように読者向け説明に" },
48
+ { pattern: /render\s*未実装/, hint: "「render 未実装」 は開発者向け内部メモ、 catalog 表示では省く" },
49
+ { pattern: /SVG\s+(polyline|arc|rect|path)/i, hint: "「SVG polyline / arc / rect / path」 は実装詳細、 「〜 を示す図」 に置換" },
50
+ { pattern: /\bpolygon\b/i, hint: "「polygon」 は実装用語、 図の意味を説明する自然文に置換" },
51
+ ];
52
+
53
+ /**
54
+ * 検査対象 diagram の全 rule を実行し LintReport を返す。
55
+ * 全 rule は純粋 (副作用なし / LLM 呼び出しなし)。
56
+ */
57
+ export function lintDiagram(d: CdlDiagram): LintReport {
58
+ const issues: LintIssue[] = [];
59
+
60
+ issues.push(...ruleTopicRedundancy(d));
61
+ issues.push(...ruleEmptyChartData(d));
62
+ issues.push(...ruleGanttUnknownDependsOn(d));
63
+ issues.push(...ruleMindMapParentReference(d));
64
+ issues.push(...ruleTreeParentReference(d));
65
+ issues.push(...ruleQuadrantMissingItems(d));
66
+ issues.push(...ruleFunnelMonotonicCount(d));
67
+
68
+ return {
69
+ diagramId: d.id,
70
+ issues,
71
+ autoFixableCount: issues.filter((i) => i.autoFixable).length,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * lintDiagram で detected な issue のうち autoFixable=true のものを機械的に適用して
77
+ * 修正済 CdlDiagram を返す。 手動修正必要な issue は残る (次回 lint 時に再検出)。
78
+ */
79
+ export function autoFix(d: CdlDiagram): CdlDiagram {
80
+ const patched: CdlDiagram = {
81
+ ...d,
82
+ topic: applyTopicAutoFix(d.topic),
83
+ nodes: d.nodes.map((n) => ({ ...n })),
84
+ };
85
+ return patched;
86
+ }
87
+
88
+ const KIND_TO_JA: Record<string, string> = {
89
+ chart: "統計チャート",
90
+ "line chart": "折れ線グラフ",
91
+ "pie chart": "円グラフ",
92
+ "bar chart": "棒グラフ",
93
+ flow: "処理の流れ",
94
+ swimlane: "スイムレーン (役割別レーン)",
95
+ sequence: "時系列のやり取り",
96
+ topology: "システム構成",
97
+ er: "テーブル関係 (ER 図)",
98
+ stateMachine: "状態遷移 (ステート図)",
99
+ stateMachine2: "拡張ステート図 (階層状態)",
100
+ infrastructure: "クラウド構成",
101
+ classDiagram: "UML クラス図",
102
+ tree: "階層ツリー",
103
+ userJourney: "ユーザージャーニー",
104
+ mindMap: "マインドマップ",
105
+ mindMapRadial: "放射状マインドマップ",
106
+ funnel: "ファネル (段階別離脱)",
107
+ quadrant: "四象限マトリクス",
108
+ gantt: "ガントチャート",
109
+ flowchart: "分岐フローチャート",
110
+ network: "ネットワーク構成",
111
+ };
112
+
113
+ function applyTopicAutoFix(topic: string): string {
114
+ // 1. 先頭の kind name を検出、 マッチしたら JA description に置換
115
+ const kindMatch = topic.match(
116
+ /^\s*(chart|flow|swimlane|sequence|topology|er|stateMachine2?|infrastructure|classDiagram|tree|userJourney|mindMap(?:Radial)?|funnel|quadrant|gantt|flowchart|network|line chart|pie chart|bar chart)\b/i,
117
+ );
118
+ if (kindMatch) {
119
+ const kind = kindMatch[1]!.toLowerCase();
120
+ const canonical = Object.keys(KIND_TO_JA).find((k) => k.toLowerCase() === kind);
121
+ if (canonical) {
122
+ return `${KIND_TO_JA[canonical]} を示す図`;
123
+ }
124
+ }
125
+
126
+ // 2. kind 名で始まらない場合は括弧内実装詳細のみ除去
127
+ let out = topic;
128
+ out = out.replace(/\s*\([^)]*(preset|render|SVG|polygon|polyline|arc|rect|path)[^)]*\)/gi, "");
129
+ out = out.replace(/\b(preset|render)\b/gi, "");
130
+ out = out.replace(/\s+/g, " ").trim();
131
+ if (out.length < 3) return "図の説明";
132
+ return out;
133
+ }
134
+
135
+ function ruleTopicRedundancy(d: CdlDiagram): LintIssue[] {
136
+ const out: LintIssue[] = [];
137
+ for (const { pattern, hint } of REDUNDANT_TOPIC_PATTERNS) {
138
+ if (pattern.test(d.topic)) {
139
+ out.push({
140
+ rule: "topic-redundant-implementation-detail",
141
+ severity: "warn",
142
+ target: d.id,
143
+ message: `topic に実装詳細が含まれる: "${d.topic}"`,
144
+ suggestion: hint,
145
+ autoFixable: true,
146
+ });
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function ruleEmptyChartData(d: CdlDiagram): LintIssue[] {
153
+ const out: LintIssue[] = [];
154
+ for (const n of d.nodes) {
155
+ if (n.kind === "chart-line" || n.kind === "chart-pie" || n.kind === "chart-bar") {
156
+ const data = n.chartData ?? [];
157
+ if (data.length === 0) {
158
+ out.push({
159
+ rule: "chart-empty-datum",
160
+ severity: "warn",
161
+ target: n.id,
162
+ message: `chart node "${n.id}" が datum 0 件、 chart は非表示になる`,
163
+ suggestion: `.datum({ id: ..., label: ..., value: ... }) を 1 件以上追加`,
164
+ autoFixable: false,
165
+ });
166
+ }
167
+ if (data.length === 1) {
168
+ out.push({
169
+ rule: "chart-single-datum",
170
+ severity: "info",
171
+ target: n.id,
172
+ message: `chart node "${n.id}" が datum 1 件、 比較 / 推移として意味が薄い`,
173
+ suggestion: `2 件以上の datum を推奨 (line 系は 3 件以上で trend が見える)`,
174
+ autoFixable: false,
175
+ });
176
+ }
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ function ruleGanttUnknownDependsOn(d: CdlDiagram): LintIssue[] {
183
+ const out: LintIssue[] = [];
184
+ for (const n of d.nodes) {
185
+ if (n.kind === "gantt-timeline") {
186
+ const tasks = n.ganttData ?? [];
187
+ const ids = new Set(tasks.map((t) => t.id));
188
+ for (const t of tasks) {
189
+ if (t.dependsOn && !ids.has(t.dependsOn)) {
190
+ out.push({
191
+ rule: "gantt-unknown-depends-on",
192
+ severity: "warn",
193
+ target: t.id,
194
+ message: `task "${t.id}" が未定義 task "${t.dependsOn}" に dependsOn 参照`,
195
+ suggestion: `参照先 id を修正 or dependsOn を除去`,
196
+ autoFixable: false,
197
+ });
198
+ }
199
+ }
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+
205
+ function ruleMindMapParentReference(d: CdlDiagram): LintIssue[] {
206
+ const out: LintIssue[] = [];
207
+ for (const n of d.nodes) {
208
+ if ((n.kind === "mind-map" || n.kind === "mind-radial") && n.mindData) {
209
+ const known = new Set<string>([n.mindData.rootId]);
210
+ for (const b of n.mindData.branches) known.add(b.id);
211
+ for (const b of n.mindData.branches) {
212
+ if (!known.has(b.parent)) {
213
+ out.push({
214
+ rule: "mindmap-unknown-parent",
215
+ severity: "warn",
216
+ target: b.id,
217
+ message: `branch "${b.id}" が未定義 parent "${b.parent}" を参照`,
218
+ suggestion: `parent を rootId ("${n.mindData.rootId}") または既存 branch id に修正`,
219
+ autoFixable: false,
220
+ });
221
+ }
222
+ }
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function ruleTreeParentReference(d: CdlDiagram): LintIssue[] {
229
+ const out: LintIssue[] = [];
230
+ for (const n of d.nodes) {
231
+ if (n.kind === "tree-hierarchy" && n.treeData) {
232
+ const ids = new Set(n.treeData.map((t) => t.id));
233
+ for (const t of n.treeData) {
234
+ if (t.parent && !ids.has(t.parent)) {
235
+ out.push({
236
+ rule: "tree-unknown-parent",
237
+ severity: "warn",
238
+ target: t.id,
239
+ message: `tree node "${t.id}" が未定義 parent "${t.parent}" を参照`,
240
+ suggestion: `parent id を既存 tree node に修正 or parent 除去 (root にする)`,
241
+ autoFixable: false,
242
+ });
243
+ }
244
+ }
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+
250
+ function ruleQuadrantMissingItems(d: CdlDiagram): LintIssue[] {
251
+ const out: LintIssue[] = [];
252
+ for (const n of d.nodes) {
253
+ if (n.kind === "quadrant-matrix" && n.quadrantData) {
254
+ const items = n.quadrantData.items;
255
+ if (items.length === 0) {
256
+ out.push({
257
+ rule: "quadrant-empty",
258
+ severity: "warn",
259
+ target: n.id,
260
+ message: `quadrant "${n.id}" が item 0 件、 軸のみ表示される`,
261
+ suggestion: `.item({ id: ..., title: ..., quadrant: "topLeft" | ... }) を 1 件以上追加`,
262
+ autoFixable: false,
263
+ });
264
+ }
265
+ const bySlot = new Set(items.map((it) => it.quadrant));
266
+ if (items.length >= 4 && bySlot.size === 1) {
267
+ out.push({
268
+ rule: "quadrant-single-quadrant",
269
+ severity: "info",
270
+ target: n.id,
271
+ message: `quadrant "${n.id}" の item が 1 象限に集中、 マトリクスの意味が薄い`,
272
+ suggestion: `2 象限以上に item を分散 (SWOT / Priority matrix 等は 4 象限 balanced を推奨)`,
273
+ autoFixable: false,
274
+ });
275
+ }
276
+ }
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function ruleFunnelMonotonicCount(d: CdlDiagram): LintIssue[] {
282
+ const out: LintIssue[] = [];
283
+ for (const n of d.nodes) {
284
+ if (n.kind === "funnel-stages" && n.funnelData) {
285
+ const stages = n.funnelData;
286
+ for (let i = 1; i < stages.length; i++) {
287
+ if (stages[i]!.count > stages[i - 1]!.count) {
288
+ out.push({
289
+ rule: "funnel-increasing-count",
290
+ severity: "warn",
291
+ target: stages[i]!.id,
292
+ message: `stage "${stages[i]!.id}" (${stages[i]!.count}) が前段 (${stages[i - 1]!.count}) より増加、 funnel は単調減少が期待される`,
293
+ suggestion: `stage 順を再確認、 増加 pattern なら別 preset (chart-line 等) を検討`,
294
+ autoFixable: false,
295
+ });
296
+ }
297
+ }
298
+ }
299
+ }
300
+ return out;
301
+ }
302
+
303
+ // dev-only import bridging (unused var lint prevention)
304
+ export type { CdlDiagram, CdlNode };
package/src/parser.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Text DSL parser
3
+ * 入力: docs/cdl/text-dsl-spec.md 準拠の箇条書きテキスト
4
+ * 出力: DslDocument AST or DslError[]
5
+ */
6
+
7
+ import type {
8
+ DslDocument,
9
+ DslActor,
10
+ DslStep,
11
+ DslAnimate,
12
+ DslState,
13
+ DslPhase,
14
+ DslError,
15
+ PresetType,
16
+ } from "./types";
17
+ import type { NodeKind, Tone } from "@cardenelabs/cdl";
18
+ import {
19
+ PRESET_NAMES,
20
+ NODE_KIND_ALIAS,
21
+ TONE_ALIAS,
22
+ normalizeArrow,
23
+ resolveHeader,
24
+ resolveAnimSubkey,
25
+ parseDuration,
26
+ } from "./keywords";
27
+
28
+ type Line = { raw: string; trimmed: string; indent: number; lineNo: number };
29
+
30
+ function indentOf(s: string): number {
31
+ let n = 0;
32
+ for (const c of s) {
33
+ if (c === " ") n += 1;
34
+ else if (c === "\t") n += 2;
35
+ else break;
36
+ }
37
+ return n;
38
+ }
39
+
40
+ function splitLines(src: string): Line[] {
41
+ return src.split("\n").map((raw, i) => {
42
+ // # コメントを除去 (行末まで)
43
+ const noComment = raw.replace(/\s*#.*$/, "");
44
+ return {
45
+ raw,
46
+ trimmed: noComment.trim(),
47
+ indent: indentOf(noComment),
48
+ lineNo: i + 1,
49
+ };
50
+ });
51
+ }
52
+
53
+ function isBlank(l: Line): boolean {
54
+ return l.trimmed === "";
55
+ }
56
+
57
+ /** "key: value" を split */
58
+ function splitKv(s: string): [string, string] | null {
59
+ const idx = s.indexOf(":");
60
+ if (idx < 0) return null;
61
+ return [s.slice(0, idx).trim(), s.slice(idx + 1).trim()];
62
+ }
63
+
64
+ /** 行の "- item (kind)" or "- item" を parse */
65
+ function parseListItem(s: string): { name: string; paren?: string } | null {
66
+ const m = s.match(/^-\s*(.+?)(?:\s*\(\s*(.+?)\s*\))?\s*$/);
67
+ if (!m) return null;
68
+ return { name: m[1]!.trim(), paren: m[2]?.trim() };
69
+ }
70
+
71
+ /**
72
+ * Parser 本体
73
+ */
74
+ export type ParseResult = { ok: true; doc: DslDocument } | { ok: false; errors: DslError[] };
75
+
76
+ export function parseTextDsl(src: string): ParseResult {
77
+ const lines = splitLines(src);
78
+ const errors: DslError[] = [];
79
+
80
+ let title: string | undefined;
81
+ let type: PresetType | undefined;
82
+ const actors: DslActor[] = [];
83
+ const steps: DslStep[] = [];
84
+ let animate: DslAnimate | undefined;
85
+
86
+ let i = 0;
87
+ while (i < lines.length) {
88
+ const l = lines[i]!;
89
+ if (isBlank(l)) { i += 1; continue; }
90
+
91
+ // top-level header の検出 (`タイトル:` 等)
92
+ const kv = splitKv(l.trimmed);
93
+ if (kv && l.indent === 0) {
94
+ const header = resolveHeader(kv[0]);
95
+ if (header === "title") {
96
+ title = kv[1];
97
+ i += 1; continue;
98
+ }
99
+ if (header === "type") {
100
+ if (!(PRESET_NAMES as readonly string[]).includes(kv[1].toLowerCase())) {
101
+ errors.push({
102
+ line: l.lineNo,
103
+ message: `未知の種類 "${kv[1]}"`,
104
+ hint: `次から選んでください: ${PRESET_NAMES.join(" / ")}`,
105
+ });
106
+ }
107
+ type = kv[1].toLowerCase() as PresetType;
108
+ i += 1; continue;
109
+ }
110
+ if (header === "actors") {
111
+ // 次のブロック (indent > 0) を全部読む
112
+ i += 1;
113
+ while (i < lines.length && (lines[i]!.indent > 0 || isBlank(lines[i]!))) {
114
+ if (!isBlank(lines[i]!)) {
115
+ const a = parseActor(lines[i]!, errors);
116
+ if (a) actors.push(a);
117
+ }
118
+ i += 1;
119
+ }
120
+ continue;
121
+ }
122
+ if (header === "flow") {
123
+ i += 1;
124
+ while (i < lines.length && (lines[i]!.indent > 0 || isBlank(lines[i]!))) {
125
+ if (!isBlank(lines[i]!)) {
126
+ const s = parseStep(lines[i]!, errors, actors);
127
+ if (s) steps.push(s);
128
+ }
129
+ i += 1;
130
+ }
131
+ continue;
132
+ }
133
+ if (header === "animate") {
134
+ const r = parseAnimate(lines, i + 1, errors);
135
+ animate = r.anim;
136
+ i = r.nextIndex;
137
+ continue;
138
+ }
139
+ }
140
+
141
+ // 知らない top-level 行はスキップ (warning に降格、 ignore)
142
+ i += 1;
143
+ }
144
+
145
+ // 必須項目チェック
146
+ if (!title) errors.push({ line: 1, message: "タイトル: が見つかりません", hint: "ファイル先頭に `タイトル: <名前>` を追加" });
147
+ if (!type) errors.push({ line: 1, message: "種類: が見つかりません", hint: "ファイルに `種類: sequence` 等を追加" });
148
+ if (actors.length === 0) errors.push({ line: 1, message: "登場人物: ブロックが空または見つかりません", hint: "`登場人物:` の下に `- 名前 (種類)` を追加" });
149
+
150
+ if (errors.length > 0) return { ok: false, errors };
151
+
152
+ return {
153
+ ok: true,
154
+ doc: {
155
+ title: title!,
156
+ type: type!,
157
+ actors,
158
+ flow: steps,
159
+ animate,
160
+ pos: { line: 1 },
161
+ },
162
+ };
163
+ }
164
+
165
+ function parseActor(l: Line, errors: DslError[]): DslActor | null {
166
+ const item = parseListItem(l.trimmed);
167
+ if (!item) {
168
+ errors.push({ line: l.lineNo, message: `登場人物の書式エラー: "${l.trimmed}"`, hint: "`- 名前 (種類)` 形式で書いてください" });
169
+ return null;
170
+ }
171
+ let kind: NodeKind = "actor";
172
+ let kindWritten = false;
173
+ if (item.paren) {
174
+ const resolved = NODE_KIND_ALIAS[item.paren] ?? (item.paren as NodeKind);
175
+ kind = resolved;
176
+ kindWritten = true;
177
+ }
178
+ return { name: item.name, kind, kindWritten, pos: { line: l.lineNo } };
179
+ }
180
+
181
+ function parseStep(l: Line, errors: DslError[], actors: DslActor[]): DslStep | null {
182
+ // "1. ユーザー → API: ログイン情報 (成功)"
183
+ const m = l.trimmed.match(/^(\d+)\.\s*(.+)$/);
184
+ if (!m) {
185
+ errors.push({ line: l.lineNo, message: `流れの書式エラー: "${l.trimmed}"`, hint: "`番号. <from> → <to>: <ラベル>` 形式" });
186
+ return null;
187
+ }
188
+ const no = parseInt(m[1]!, 10);
189
+ const rest = normalizeArrow(m[2]!);
190
+ // "A → B: label (tone)"
191
+ const m2 = rest.match(/^(.+?)\s*→\s*(.+?)\s*:\s*(.+)$/);
192
+ if (!m2) {
193
+ errors.push({ line: l.lineNo, message: `矢印または ラベル なし: "${l.trimmed}"`, hint: "`<from> → <to>: <ラベル>` 形式" });
194
+ return null;
195
+ }
196
+ const from = m2[1]!.trim();
197
+ const to = m2[2]!.trim();
198
+ let labelPart = m2[3]!.trim();
199
+
200
+ // tone in () at end
201
+ let tone: Tone | undefined;
202
+ const toneMatch = labelPart.match(/^(.+?)\s*\(\s*(.+?)\s*\)\s*$/);
203
+ if (toneMatch) {
204
+ const resolved = TONE_ALIAS[toneMatch[2]!];
205
+ if (resolved) {
206
+ tone = resolved;
207
+ labelPart = toneMatch[1]!.trim();
208
+ }
209
+ }
210
+
211
+ // sub label syntax: "label (sub)" → label, sub の使い分けは tone と曖昧。
212
+ // 仕様 ... 末尾 (...) は tone を優先解釈、 tone エイリアスに一致しなければ sub。
213
+ let sub: string | undefined;
214
+ if (!tone && toneMatch) {
215
+ sub = toneMatch[2];
216
+ labelPart = toneMatch[1]!.trim();
217
+ }
218
+
219
+ // actor 名チェック
220
+ const names = new Set(actors.map((a) => a.name));
221
+ if (!names.has(from)) {
222
+ errors.push({ line: l.lineNo, message: `"${from}" が登場人物にいません`, hint: `登場人物: に "- ${from}" を追加` });
223
+ }
224
+ if (!names.has(to)) {
225
+ errors.push({ line: l.lineNo, message: `"${to}" が登場人物にいません`, hint: `登場人物: に "- ${to}" を追加` });
226
+ }
227
+
228
+ return { no, from, to, label: labelPart, sub, tone, pos: { line: l.lineNo } };
229
+ }
230
+
231
+ type ParseAnimResult = { anim: DslAnimate; nextIndex: number };
232
+
233
+ function parseAnimate(lines: Line[], startIdx: number, errors: DslError[]): ParseAnimResult {
234
+ const states: DslState[] = [];
235
+ const phases: DslPhase[] = [];
236
+ const anim: DslAnimate = { states, phases, pos: { line: startIdx + 1 } };
237
+
238
+ let i = startIdx;
239
+ while (i < lines.length) {
240
+ const l = lines[i]!;
241
+ if (isBlank(l)) { i += 1; continue; }
242
+ // animate ブロックは indent > 0 が前提、 indent=0 なら top-level に戻る
243
+ if (l.indent === 0) break;
244
+
245
+ const kv = splitKv(l.trimmed);
246
+ if (kv) {
247
+ // kv[0] は "ステップ「送信」 1.5 秒" のような形 もあり得るので prefix 一致で resolve
248
+ // 「ステップ」 「step」 で始まる → "step"、 それ以外は exact match
249
+ let sub: ReturnType<typeof resolveAnimSubkey> = null;
250
+ const head = kv[0].trim();
251
+ if (/^(ステップ|step|STEP)/i.test(head)) sub = "step";
252
+ else sub = resolveAnimSubkey(head);
253
+ if (sub === "state") {
254
+ // "状態: 残高 = 100"
255
+ const m = kv[1].match(/^(.+?)\s*=\s*(.+)$/);
256
+ if (!m) {
257
+ errors.push({ line: l.lineNo, message: `状態 書式エラー: "${kv[1]}"`, hint: "`状態: <名前> = <初期値>`" });
258
+ } else {
259
+ const name = m[1]!.trim();
260
+ const raw = m[2]!.trim();
261
+ const num = parseFloat(raw);
262
+ const initial = isNaN(num) ? raw.replace(/^["']|["']$/g, "") : num;
263
+ states.push({ name, initial, pos: { line: l.lineNo } });
264
+ }
265
+ i += 1; continue;
266
+ }
267
+ if (sub === "step") {
268
+ // "ステップ「送信」 1.5 秒" or "step \"submit\" 1.5s"
269
+ // kv[0] には keyword + 「name」 + duration、 kv[1] は空 (`ステップ「送信」 1.5 秒:` の場合)
270
+ // ただし resolveAnimSubkey は kv[0] の最初の単語 (ステップ) だけ見るので、
271
+ // 残りは kv[0] 全体から keyword を除去して name + duration を抽出する。
272
+ const afterKeyword = kv[0]
273
+ .replace(/^(ステップ|step|STEP)\s*/i, "")
274
+ .trim();
275
+ const nameMatch = afterKeyword.match(/^[「"](.+?)[」"]\s*(.+)$/);
276
+ if (!nameMatch) {
277
+ errors.push({ line: l.lineNo, message: `ステップ 書式エラー: "${kv[0]}"`, hint: `\`ステップ「<名前>」 <時間>:\` 形式` });
278
+ i += 1; continue;
279
+ }
280
+ const phaseName = nameMatch[1]!;
281
+ const durStr = nameMatch[2]!.trim();
282
+ const durMs = parseDuration(durStr);
283
+ if (durMs === null) {
284
+ errors.push({ line: l.lineNo, message: `時間 解釈不能: "${durStr}"`, hint: "`1.5 秒` / `1500ms` / `2s` 等" });
285
+ i += 1; continue;
286
+ }
287
+ const phase: DslPhase = { name: phaseName, durationMs: durMs, pos: { line: l.lineNo } };
288
+ // sub block を読む
289
+ i += 1;
290
+ const phaseBaseIndent = l.indent;
291
+ while (i < lines.length && (lines[i]!.indent > phaseBaseIndent || isBlank(lines[i]!))) {
292
+ if (!isBlank(lines[i]!)) {
293
+ applyPhaseSubLine(lines[i]!, phase, errors);
294
+ }
295
+ i += 1;
296
+ }
297
+ phases.push(phase);
298
+ continue;
299
+ }
300
+ }
301
+ i += 1;
302
+ }
303
+
304
+ return { anim, nextIndex: i };
305
+ }
306
+
307
+ function applyPhaseSubLine(l: Line, phase: DslPhase, errors: DslError[]): void {
308
+ const kv = splitKv(l.trimmed);
309
+ if (!kv) return;
310
+ const sub = resolveAnimSubkey(kv[0]);
311
+ if (sub === "highlight") {
312
+ phase.highlight = kv[1].split(",").map((s) => s.trim()).filter(Boolean);
313
+ return;
314
+ }
315
+ if (sub === "tween") {
316
+ // "残高: 100 → 90"
317
+ const rest = normalizeArrow(kv[1]);
318
+ const m = rest.match(/^(.+?)\s*:\s*([\d.]+)\s*→\s*([\d.]+)$/);
319
+ if (!m) {
320
+ errors.push({ line: l.lineNo, message: `遷移 書式エラー: "${kv[1]}"`, hint: "`遷移: <state>: <from> → <to>`" });
321
+ return;
322
+ }
323
+ phase.tweens = phase.tweens ?? [];
324
+ phase.tweens.push({
325
+ state: m[1]!.trim(),
326
+ from: parseFloat(m[2]!),
327
+ to: parseFloat(m[3]!),
328
+ pos: { line: l.lineNo },
329
+ });
330
+ return;
331
+ }
332
+ if (sub === "set") {
333
+ // "ステータス: 完了"
334
+ const m = kv[1].match(/^(.+?)\s*:\s*(.+)$/);
335
+ if (!m) {
336
+ errors.push({ line: l.lineNo, message: `切替 書式エラー: "${kv[1]}"`, hint: "`切替: <state>: <値>`" });
337
+ return;
338
+ }
339
+ const raw = m[2]!.trim();
340
+ const num = parseFloat(raw);
341
+ const value = isNaN(num) ? raw : num;
342
+ phase.sets = phase.sets ?? [];
343
+ phase.sets.push({ state: m[1]!.trim(), value, pos: { line: l.lineNo } });
344
+ return;
345
+ }
346
+ if (sub === "body") {
347
+ phase.body = kv[1];
348
+ return;
349
+ }
350
+ if (sub === "badge") {
351
+ phase.badge = kv[1];
352
+ return;
353
+ }
354
+ }