@wscytz/dsl 0.0.0-stage → 0.1.1

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,683 @@
1
+ /**
2
+ * DSL Parser — parses AI output (or pasted/edited DSL) for canvas layout.
3
+ *
4
+ * 架构(正则 → PEG 升级,2026-07):
5
+ * dsl.peggy(Peggy 语法,结构 tokenizer)—— `pnpm --filter @wscytz/dsl gen` → dsl-parser.gen.js
6
+ * 本文件(TS 包装层)—— 按行调用生成 parser,做语义 coercion:
7
+ * Number / finite 守卫 / escape unescape / 颜色枚举校验 / elbow 拆分 / DSL_MAX_TEXT_LEN 截断
8
+ * 永不抛错 + 坐标恒有限 + byte-equal round-trip 契约不变(见 dsl-* 测试)。
9
+ *
10
+ * 行级解析:按 \n 切行,逐行 parse Line 规则 → {kind, ds} 结构 → build{Card,Arrow,Free}
11
+ * 组装 DslOp / 记 diagnostic。`#id` 也是 directive(等价旧"整行任意位置提取");directive*
12
+ * 内的 skipChunk 吃未知残余 → 复刻旧正则的任意位置/任意顺序容错。
13
+ */
14
+ import { DSL_COLORS, DSL_COLOR_ALIASES, DSL_MAX_TEXT_LEN, DSL_MAX_CONTENT_LEN, DSL_MAX_HREF_TARGETS, DSL_MAX_TAG_COUNT, DSL_MAX_LINK_COUNT, DSL_MAX_CODE_BLOCKS, DSL_MAX_QUOTES, DSL_MAX_OPS, DSL_CARD_TYPES, truncateDslText, } from './dsl-grammar.js';
15
+ // Peggy 生成;类型垫片见 dsl-parser.gen.d.ts
16
+ import { parse as parseLine } from './dsl-parser.gen.js';
17
+ // ── 语义 coercion(算法搬迁自旧 extract*,行为等价)──────────────────────────────
18
+ /** parser 接受的颜色集合 = DSL_COLORS ∪ aliases 键(dsl-sync 锁)。grey 存原样不归一。 */
19
+ const ACCEPTED_COLORS = new Set([
20
+ ...DSL_COLORS,
21
+ ...Object.keys(DSL_COLOR_ALIASES),
22
+ ]);
23
+ /** 字符串 → 有限数;非有限(NaN/Infinity)归 0 —— 守 robustness 契约"坐标恒有限"。 */
24
+ function finiteNum(raw) {
25
+ const n = Number(raw);
26
+ return Number.isFinite(n) ? n : 0;
27
+ }
28
+ /** 颜色校验:接受 canonical ∪ 别名键;越界(green 等)→ undefined(不静默变黑)。 */
29
+ function validColor(raw) {
30
+ return ACCEPTED_COLORS.has(raw) ? raw : undefined;
31
+ }
32
+ /** 枚举校验:grammar 捕获原始串(`[^)]+`),这里收窄到合法值;越界 → undefined。 */
33
+ function validEnum(raw, list) {
34
+ return list.includes(raw) ? raw : undefined;
35
+ }
36
+ /** 默认截断到 DSL_MAX_TEXT_LEN(@text/@label/@title,int 级)。@content 用
37
+ * truncateDslText(…, DSL_MAX_CONTENT_LEN)(long 级)。截断实现共用 dsl-grammar 的
38
+ * truncateDslText —— 代理对安全(不劈开 emoji),且 parser/sanitize 单一实现(G/H)。 */
39
+ function truncate(v) {
40
+ return truncateDslText(v, DSL_MAX_TEXT_LEN);
41
+ }
42
+ /** quoted-string 解码,是 escapeQuoted 的逆。Peggy escChar = '\\' . —— 任何 \X 转义对;
43
+ * \n→换行(v5,@content 多行 markdown),其余 \X→X(\"→", \\→\)。 */
44
+ function unescapeQuoted(v) {
45
+ return v.replace(/\\(.)/g, (_, c) => (c === 'n' ? '\n' : c));
46
+ }
47
+ /** @elbow(x,y;x,y) 拆分:分号分隔,每个折点 x,y(支持负);过滤坏点,保好点(复刻 extractElbow)。 */
48
+ function parseElbow(raw) {
49
+ const pts = raw
50
+ .split(';')
51
+ .map((pair) => pair.trim().match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/))
52
+ .filter(Boolean)
53
+ .map((pm) => ({ x: Number(pm[1]), y: Number(pm[2]) }));
54
+ return pts.length >= 1 ? pts.slice(0, 2) : undefined;
55
+ }
56
+ /** @href(#a;#b) 拆分(v7):`;` 分隔目标 id,每个可有可无 `#` 前缀(归一去掉)。
57
+ * 校验每个目标匹配 idChars(字母/数字/_/:-);过滤坏目标保好的,去重(保序),
58
+ * 截断到 DSL_MAX_HREF_TARGETS(防 LLM 失控膨胀)。无有效目标 → undefined。 */
59
+ function parseHref(raw) {
60
+ const seen = new Set();
61
+ const out = [];
62
+ for (const part of raw.split(';')) {
63
+ const id = part.trim().replace(/^#/, '');
64
+ if (!/^[a-zA-Z0-9_:-]+$/.test(id))
65
+ continue; // 空 / 含非法字符 → 丢
66
+ if (seen.has(id))
67
+ continue; // 去重保序
68
+ seen.add(id);
69
+ out.push(id);
70
+ if (out.length >= DSL_MAX_HREF_TARGETS)
71
+ break; // 截断到上限
72
+ }
73
+ return out.length >= 1 ? out : undefined;
74
+ }
75
+ /** v8:卡片类型枚举用 dsl-grammar 的 DSL_CARD_TYPES(单一可信源;parser/sanitize 共用)。 */
76
+ /** v8:可重复指令(strict 模式 dup 检查豁免)—— @code/@quote 一指令一块/条,多个是合法的多块/多引文,
77
+ * 不是重复错误。@tags/@links 仍是单指令列表(重复算 dup)。 */
78
+ const REPEATABLE_DIRECTIVES = new Set(['code', 'quote']);
79
+ /** @tags(a;b;c) 拆分(v8):`;` 分隔,每个 trim + decodeURIComponent(序列化侧 encode 防碰撞);
80
+ * 过滤空值,去重保序,截断到 DSL_MAX_TAG_COUNT。无有效 tag → undefined。是 metaTags 的逆。 */
81
+ function parseTagList(raw) {
82
+ const seen = new Set();
83
+ const out = [];
84
+ for (const part of raw.split(';')) {
85
+ let value = part.trim();
86
+ if (value === '')
87
+ continue;
88
+ try {
89
+ value = decodeURIComponent(value);
90
+ }
91
+ catch {
92
+ // 非法百分号编码(如裸 `%`)→ 用原串,不丢
93
+ }
94
+ if (value === '' || seen.has(value))
95
+ continue;
96
+ seen.add(value);
97
+ out.push(value);
98
+ if (out.length >= DSL_MAX_TAG_COUNT)
99
+ break;
100
+ }
101
+ return out.length >= 1 ? out : undefined;
102
+ }
103
+ /** @links(<enc-url>;…) 拆分(v8):`;` 分隔,每个 trim + decodeURIComponent(序列化侧 encode,
104
+ * URL 含 `;`/`=`/`&`);过滤空值,去重保序,截断到 DSL_MAX_LINK_COUNT。无有效 url → undefined。
105
+ * 产出 **URL 字符串列表**(apply 层映射 LinkPreview + fetchedAt)。是 metaLinks 的逆。 */
106
+ function parseLinkList(raw) {
107
+ const seen = new Set();
108
+ const out = [];
109
+ for (const part of raw.split(';')) {
110
+ let url = part.trim();
111
+ if (url === '')
112
+ continue;
113
+ try {
114
+ url = decodeURIComponent(url);
115
+ }
116
+ catch {
117
+ // 非法百分号编码 → 用原串,不丢
118
+ }
119
+ if (url === '' || seen.has(url))
120
+ continue;
121
+ seen.add(url);
122
+ out.push(url);
123
+ if (out.length >= DSL_MAX_LINK_COUNT)
124
+ break;
125
+ }
126
+ return out.length >= 1 ? out : undefined;
127
+ }
128
+ function fold(ds, warns) {
129
+ const a = {};
130
+ for (const d of ds) {
131
+ if (!Array.isArray(d))
132
+ continue; // null(skipChunk)/非元组 → 跳过
133
+ const [tag, v1, v2, v3] = d;
134
+ switch (tag) {
135
+ case 'id':
136
+ if (a.id === undefined)
137
+ a.id = String(v1);
138
+ break;
139
+ case 'pos':
140
+ if (!a.pos)
141
+ a.pos = [finiteNum(String(v1)), finiteNum(String(v2))];
142
+ break;
143
+ case 'size':
144
+ if (!a.size)
145
+ a.size = { w: finiteNum(String(v1)), h: finiteNum(String(v2)) };
146
+ break;
147
+ case 'color':
148
+ if (a.color === undefined)
149
+ a.color = validColor(String(v1));
150
+ break;
151
+ case 'label':
152
+ if (a.label === undefined)
153
+ a.label = truncate(unescapeQuoted(String(v1)));
154
+ break;
155
+ case 'text':
156
+ if (a.text === undefined)
157
+ a.text = truncate(unescapeQuoted(String(v1)));
158
+ break;
159
+ case 'title':
160
+ if (a.title === undefined)
161
+ a.title = truncate(unescapeQuoted(String(v1)));
162
+ break;
163
+ case 'content':
164
+ if (a.content === undefined)
165
+ a.content = truncateDslText(unescapeQuoted(String(v1)), DSL_MAX_CONTENT_LEN);
166
+ break;
167
+ case 'dash':
168
+ if (!a.dash)
169
+ a.dash = validEnum(String(v1), ['solid', 'dashed', 'dotted']);
170
+ break;
171
+ case 'arrowhead':
172
+ if (!a.arrowhead)
173
+ a.arrowhead = validEnum(String(v1), ['arrow', 'triangle', 'none']);
174
+ break;
175
+ case 'route':
176
+ if (!a.route)
177
+ a.route = validEnum(String(v1), ['straight', 'curve', 'elbow']);
178
+ break;
179
+ case 'curve':
180
+ if (!a.curve)
181
+ a.curve = { cx: finiteNum(String(v1)), cy: finiteNum(String(v2)) };
182
+ break;
183
+ case 'elbow':
184
+ if (!a.elbow)
185
+ a.elbow = parseElbow(String(v1));
186
+ break;
187
+ case 'rel': {
188
+ if (!a.rel) {
189
+ // v9:锚位三形态 —— '#id'(卡锚)/ '@裸名'(组锚,CJK 可)/ '@Q引号名'(组锚,任意名)。
190
+ // 保留指令词裸名 grammar 故意放行,由 sanitize 产诊断并剥 rel(保精确报错)。
191
+ const anchor = String(v2);
192
+ if (anchor.startsWith('@Q')) {
193
+ a.rel = {
194
+ dir: String(v1),
195
+ anchor: unescapeQuoted(anchor.slice(2)),
196
+ anchorGroup: true,
197
+ anchorQuoted: true,
198
+ };
199
+ }
200
+ else if (anchor.startsWith('@')) {
201
+ a.rel = { dir: String(v1), anchor: anchor.slice(1), anchorGroup: true };
202
+ }
203
+ else {
204
+ a.rel = { dir: String(v1), anchor };
205
+ }
206
+ }
207
+ break;
208
+ }
209
+ case 'gap':
210
+ if (a.gap === undefined)
211
+ a.gap = finiteNum(String(v1));
212
+ break;
213
+ case 'create':
214
+ if (!a.create)
215
+ a.create = true;
216
+ break;
217
+ case 'from':
218
+ if (!a.from)
219
+ a.from = String(v1);
220
+ break;
221
+ case 'to':
222
+ if (!a.to)
223
+ a.to = String(v1);
224
+ break;
225
+ case 'wikilink':
226
+ if (!a.wikilink)
227
+ a.wikilink = true;
228
+ break;
229
+ case 'group':
230
+ if (a.group === undefined)
231
+ a.group = truncate(unescapeQuoted(String(v1)));
232
+ break;
233
+ case 'href': {
234
+ if (!a.href) {
235
+ const raw = String(v1);
236
+ a.href = parseHref(raw);
237
+ // 超限截断信号(2026-10-29):此前 30→20 静默,数据丢失无信号
238
+ if (a.href && warns && raw.split(';').filter((s) => s.trim() !== '').length > a.href.length) {
239
+ warns.push(`@href truncated to ${DSL_MAX_HREF_TARGETS} targets (多余的 ${DSL_MAX_HREF_TARGETS} 之后的条目被截断)`);
240
+ }
241
+ }
242
+ break;
243
+ }
244
+ case 'compute':
245
+ if (a.compute === undefined)
246
+ a.compute = truncate(unescapeQuoted(String(v1)));
247
+ break;
248
+ case 'type': {
249
+ if (a.cardType === undefined) {
250
+ const t = validEnum(String(v1), DSL_CARD_TYPES);
251
+ if (t !== undefined)
252
+ a.cardType = t; // 非法 type(green 等)→ 不设,不静默变 note
253
+ }
254
+ break;
255
+ }
256
+ case 'tags': {
257
+ if (!a.tags) {
258
+ const raw = String(v1);
259
+ a.tags = parseTagList(raw);
260
+ if (a.tags && warns && raw.split(';').filter((s) => s.trim() !== '').length > a.tags.length) {
261
+ warns.push(`@tags truncated to ${DSL_MAX_TAG_COUNT} entries`);
262
+ }
263
+ }
264
+ break;
265
+ }
266
+ case 'links': {
267
+ if (!a.links) {
268
+ const raw = String(v1);
269
+ a.links = parseLinkList(raw);
270
+ if (a.links && warns && raw.split(';').filter((s) => s.trim() !== '').length > a.links.length) {
271
+ warns.push(`@links truncated to ${DSL_MAX_LINK_COUNT} entries`);
272
+ }
273
+ }
274
+ break;
275
+ }
276
+ case 'code': {
277
+ // 可重复指令 → 累积成数组(非首遇优先);截断到 DSL_MAX_CODE_BLOCKS。code 走 long 级截断。
278
+ if (a.code === undefined)
279
+ a.code = [];
280
+ if (a.code.length < DSL_MAX_CODE_BLOCKS) {
281
+ const code = truncateDslText(unescapeQuoted(String(v2)), DSL_MAX_CONTENT_LEN);
282
+ const caption = v3 == null ? undefined : unescapeQuoted(String(v3));
283
+ a.code.push({
284
+ language: String(v1),
285
+ code,
286
+ ...(caption ? { caption } : {}),
287
+ });
288
+ }
289
+ else if (warns && !warns.some((w) => w.includes('@code'))) {
290
+ warns.push(`@code truncated to ${DSL_MAX_CODE_BLOCKS} blocks (第 ${DSL_MAX_CODE_BLOCKS + 1} 块起被截断)`);
291
+ }
292
+ break;
293
+ }
294
+ case 'quote': {
295
+ // 可重复指令 → 累积成数组;截断到 DSL_MAX_QUOTES。text 走 long 级截断;空串占位归一 undefined。
296
+ if (a.quotes === undefined)
297
+ a.quotes = [];
298
+ if (a.quotes.length < DSL_MAX_QUOTES) {
299
+ const text = truncateDslText(unescapeQuoted(String(v1)), DSL_MAX_CONTENT_LEN);
300
+ const attribution = v2 == null ? undefined : unescapeQuoted(String(v2));
301
+ const sourceUrl = v3 == null ? undefined : unescapeQuoted(String(v3));
302
+ a.quotes.push({
303
+ text,
304
+ ...(attribution ? { attribution } : {}),
305
+ ...(sourceUrl ? { sourceUrl } : {}),
306
+ });
307
+ }
308
+ else if (warns && !warns.some((w) => w.includes('@quote'))) {
309
+ warns.push(`@quote truncated to ${DSL_MAX_QUOTES} entries (第 ${DSL_MAX_QUOTES + 1} 条起被截断)`);
310
+ }
311
+ break;
312
+ }
313
+ }
314
+ }
315
+ return a;
316
+ }
317
+ /** v8:把 fold 出的结构化字段(type/tags/links/code/quotes)拷到 card op —— 镜像 title/content/group/href
318
+ * 的 if-set(缺省不带,apply 侧"缺省不改")。三个 buildCard 分支共用,消除重复。 */
319
+ function applyCardFieldsV8(op, d) {
320
+ if (d.cardType !== undefined)
321
+ op.cardType = d.cardType;
322
+ if (d.tags !== undefined)
323
+ op.tags = d.tags;
324
+ if (d.links !== undefined)
325
+ op.links = d.links;
326
+ if (d.code !== undefined)
327
+ op.code = d.code;
328
+ if (d.quotes !== undefined)
329
+ op.quotes = d.quotes;
330
+ }
331
+ function buildCard(ds) {
332
+ const warns = [];
333
+ const d = fold(ds, warns);
334
+ if (d.id === undefined)
335
+ return { diag: 'missing #id' };
336
+ if (d.rel) {
337
+ const op = {
338
+ type: 'card',
339
+ cardId: d.id,
340
+ x: d.pos ? d.pos[0] : 0,
341
+ y: d.pos ? d.pos[1] : 0,
342
+ w: d.size?.w,
343
+ h: d.size?.h,
344
+ color: d.color,
345
+ rel: {
346
+ ...d.rel, // v9:保留 anchorGroup(组锚)
347
+ dir: d.rel.dir,
348
+ gap: d.gap ?? 20,
349
+ },
350
+ };
351
+ if (d.create)
352
+ op.create = true;
353
+ if (d.title !== undefined)
354
+ op.title = d.title;
355
+ if (d.content !== undefined)
356
+ op.content = d.content;
357
+ if (d.group !== undefined)
358
+ op.group = d.group;
359
+ if (d.href !== undefined)
360
+ op.href = d.href;
361
+ applyCardFieldsV8(op, d);
362
+ return warns.length ? { op, warns } : { op };
363
+ }
364
+ if (d.pos) {
365
+ const op = {
366
+ type: 'card',
367
+ cardId: d.id,
368
+ x: d.pos[0],
369
+ y: d.pos[1],
370
+ w: d.size?.w,
371
+ h: d.size?.h,
372
+ color: d.color,
373
+ };
374
+ if (d.create)
375
+ op.create = true;
376
+ if (d.title !== undefined)
377
+ op.title = d.title;
378
+ if (d.content !== undefined)
379
+ op.content = d.content;
380
+ if (d.group !== undefined)
381
+ op.group = d.group;
382
+ if (d.href !== undefined)
383
+ op.href = d.href;
384
+ applyCardFieldsV8(op, d);
385
+ return warns.length ? { op, warns } : { op };
386
+ }
387
+ // v5(E):无 @pos 的非 create 卡片行,若携带 title/content/color/size 之一 = "纯属性/内容编辑"
388
+ // (几何沿用现有卡,由 planCard 处理)。x/y 占位(0,0);裸行(无任何字段)与 create 仍 missing @pos。
389
+ // v7:group/href 同为"无几何属性编辑"——给现有卡分组/加出链不该要求 @pos。
390
+ // v8:type/tags/links/code/quotes 同理——给现有卡改结构化字段不要求 @pos。
391
+ if (!d.create &&
392
+ (d.title !== undefined || d.content !== undefined || d.color !== undefined || d.size !== undefined ||
393
+ d.group !== undefined || d.href !== undefined || d.cardType !== undefined || d.tags !== undefined ||
394
+ d.links !== undefined || d.code !== undefined || d.quotes !== undefined)) {
395
+ const op = {
396
+ type: 'card',
397
+ cardId: d.id,
398
+ x: 0,
399
+ y: 0,
400
+ keepExistingPos: true,
401
+ ...(d.color !== undefined ? { color: d.color } : {}),
402
+ ...(d.size ? { w: d.size.w, h: d.size.h } : {}),
403
+ ...(d.title !== undefined ? { title: d.title } : {}),
404
+ ...(d.content !== undefined ? { content: d.content } : {}),
405
+ ...(d.group !== undefined ? { group: d.group } : {}),
406
+ ...(d.href !== undefined ? { href: d.href } : {}),
407
+ };
408
+ applyCardFieldsV8(op, d);
409
+ return { op };
410
+ }
411
+ return { diag: 'missing @pos' };
412
+ }
413
+ function buildArrow(ds) {
414
+ const d = fold(ds);
415
+ const common = {
416
+ id: d.id,
417
+ label: d.label,
418
+ color: d.color,
419
+ dash: d.dash,
420
+ arrowhead: d.arrowhead,
421
+ curve: d.curve,
422
+ route: d.route,
423
+ elbow: d.elbow,
424
+ wikilink: d.wikilink || undefined,
425
+ };
426
+ if (d.from && d.to) {
427
+ const op = {
428
+ type: 'arrow',
429
+ from: d.from,
430
+ to: d.to,
431
+ ...common,
432
+ };
433
+ return { op };
434
+ }
435
+ if (d.pos && d.size) {
436
+ const op = {
437
+ type: 'arrow',
438
+ from: '',
439
+ to: '',
440
+ freeArrow: true,
441
+ x: d.pos[0],
442
+ y: d.pos[1],
443
+ w: d.size.w,
444
+ h: d.size.h,
445
+ ...common,
446
+ };
447
+ return { op };
448
+ }
449
+ return { diag: 'free arrow missing @pos/@size' };
450
+ }
451
+ function buildFree(kind, ds) {
452
+ const d = fold(ds);
453
+ if (d.id === undefined)
454
+ return { diag: 'missing #id' };
455
+ if (!d.pos)
456
+ return { diag: 'missing @pos' };
457
+ if (kind === 'rect') {
458
+ const op = {
459
+ type: 'free', shape: 'rect', id: d.id,
460
+ x: d.pos[0], y: d.pos[1], w: d.size?.w, h: d.size?.h, color: d.color,
461
+ ...(d.group !== undefined ? { group: d.group } : {}),
462
+ };
463
+ return { op };
464
+ }
465
+ if (kind === 'text') {
466
+ const op = {
467
+ type: 'free', shape: 'text', id: d.id,
468
+ x: d.pos[0], y: d.pos[1], text: d.text, color: d.color,
469
+ ...(d.group !== undefined ? { group: d.group } : {}),
470
+ ...(d.compute !== undefined ? { compute: d.compute } : {}),
471
+ };
472
+ return { op };
473
+ }
474
+ const op = {
475
+ type: 'free', shape: 'frame', id: d.id,
476
+ x: d.pos[0], y: d.pos[1], w: d.size?.w, h: d.size?.h, text: d.text, color: d.color,
477
+ ...(d.group !== undefined ? { group: d.group } : {}),
478
+ };
479
+ return { op };
480
+ }
481
+ function buildOp(r) {
482
+ if (r === null)
483
+ return null; // 静默 skip
484
+ if (r.kind === 'unknown')
485
+ return { diag: 'unrecognized element kind' };
486
+ if (r.kind === 'card')
487
+ return buildCard(r.ds);
488
+ if (r.kind === 'arrow')
489
+ return buildArrow(r.ds);
490
+ return buildFree(r.kind, r.ds); // rect / text / frame
491
+ }
492
+ // ── Parser ───────────────────────────────────────────────────────────────────
493
+ /**
494
+ * Parse a DSL block into ops AND per-line diagnostics for dropped lines.
495
+ *
496
+ * 行分类(保留 1-based 原始行号,编辑器告诉用户哪行被丢、为什么 —— 转义核心卖点的反信任问题):
497
+ *
498
+ * - empty / `# comment` / 非 `[` 散文 / 围栏 → 静默 skip(无 error)
499
+ * - `[` 行解析后缺关键字段 → 记 {@link DslDiagnostic}
500
+ *
501
+ * 永不抛错:grammar 本身因尾部 `.*` 对任何 `[` 行都 succeed;极端输入(控制字符等)若仍
502
+ * 抛 → 该行静默跳过(不崩整块)。坐标恒 finite(finiteNum 守卫)。
503
+ */
504
+ export function parseDslWithDiagnostics(dslText) {
505
+ const ops = [];
506
+ const errors = [];
507
+ const warnings = [];
508
+ const rawLines = dslText.split('\n');
509
+ for (let i = 0; i < rawLines.length; i++) {
510
+ const lineNo = i + 1;
511
+ const line = rawLines[i].trim();
512
+ if (!line)
513
+ continue;
514
+ let result;
515
+ try {
516
+ result = parseLine(line, { startRule: 'Line' });
517
+ }
518
+ catch {
519
+ // grammar 理论上不抛(尾部 .* 兜底);真抛了 → 静默跳过该行(永不抛错契约)
520
+ continue;
521
+ }
522
+ const built = buildOp(result);
523
+ if (built === null)
524
+ continue; // prose skip
525
+ if ('op' in built) {
526
+ if (ops.length >= DSL_MAX_OPS) {
527
+ errors.push({ line: lineNo, text: line, message: `元素数已达上限 ${DSL_MAX_OPS},本行及后续被截断` });
528
+ break;
529
+ }
530
+ ops.push(built.op);
531
+ // 自然语言体验(2026-10-29 精确化):skipChunk 的 null 是 `]`(每行常客,设计内静默);
532
+ // string 才是真 junk 原文。junk 存在才分类;@词/关系词检查只看 junk——旧整行 @ 正则
533
+ // 会把合法裸组锚 @g/@methods 误报成 unknown @(v9 文档形态!),junk 视角根除。
534
+ if (result !== null && result.kind !== 'unknown' && Array.isArray(result.ds)) {
535
+ const junk = result.ds.filter((e) => typeof e === 'string');
536
+ if (junk.length > 0) {
537
+ const hint = classifyResidue(junk, line);
538
+ if (hint)
539
+ warnings.push({ line: lineNo, text: line, message: hint });
540
+ }
541
+ }
542
+ // 可重复指令/列表超限截断信号(30→8 之类此前零信号)
543
+ if ('warns' in built && built.warns) {
544
+ for (const w of built.warns)
545
+ warnings.push({ line: lineNo, text: line, message: w });
546
+ }
547
+ }
548
+ else {
549
+ errors.push({ line: lineNo, text: line, message: built.diag });
550
+ }
551
+ }
552
+ return { ops, errors, warnings };
553
+ }
554
+ /** grammar 全部 @指令名(来源:dsl.peggy 逐条 Dir 规则的 '@' 字面量,实测 22 个)。
555
+ * 注意 from/to/create/关系词是**裸词**(fromDir='from' sp id 等,不带 @);
556
+ * adversarial.test.ts 与 DSL_RESERVED_GROUP_ANCHOR_WORDS 做双向闭合锁,漂移即炸。 */
557
+ export const AT_DIRECTIVES = new Set([
558
+ 'pos', 'size', 'color', 'label', 'text', 'title', 'content', 'dash', 'arrowhead',
559
+ 'curve', 'route', 'elbow', 'gap', 'wikilink', 'group', 'href',
560
+ 'compute', 'type', 'tags', 'links', 'code', 'quote',
561
+ ]);
562
+ /** 保留词表里的裸关键词(非 @ 指令):createDir / relationDir / fromDir / toDir。 */
563
+ export const BARE_KEYWORDS = new Set(['create', 'right-of', 'below', 'from', 'to']);
564
+ const REL_KEYWORDS = new Set(['right-of', 'below']);
565
+ /**
566
+ * junk 分类(2026-10-29 起 junk 是 skipChunk 返回的原文数组):错误型 → 一句话可操作
567
+ * hint;恢复型(:1 等)→ null(静默)。@词与关系词检查只看 junk(锚位的合法 @组名
568
+ * 已被 grammar 消费,不会出现在 junk 里);全角/中文方位词看引号外的整行。
569
+ */
570
+ function classifyResidue(junk, line) {
571
+ const outsideQuotes = line.replace(/"(?:[^"\\]|\\.)*"/g, '');
572
+ if (/[(){}【】「」:;,。、]/.test(outsideQuotes)) {
573
+ return 'full-width punctuation (),: outside quotes — use ASCII ( ) , : (全角标点请用半角)';
574
+ }
575
+ const joined = junk.join(' ');
576
+ for (const m of joined.matchAll(/@([a-zA-Z][a-zA-Z0-9_-]*)/g)) {
577
+ if (!AT_DIRECTIVES.has(m[1])) {
578
+ return `unknown @${m[1]} directive — known: @pos @size @title …(见语法参考)`;
579
+ }
580
+ }
581
+ for (const w of joined.split(/\s+/).filter(Boolean)) {
582
+ // 方向词开头但不是合法关系词(leftof / left_of / aboves…)→ 反方向用 right-of 表达
583
+ if (/^(left|right|above|below)/i.test(w) && !REL_KEYWORDS.has(w.toLowerCase()) && !w.startsWith('#')) {
584
+ return `unknown relation "${w}" — relations are right-of / below (opposite direction: swap operands)`;
585
+ }
586
+ }
587
+ // 中文方位词(在#a左边/往右侧/上方…)→ 同类 hint(引号内不误报;行尾 .* 兜尾本就静默)
588
+ if (/[上下左右][边面方侧]/.test(outsideQuotes)) {
589
+ return 'CJK direction word — relational forms are right-of / below, anchor first (中文方位词请用 right-of/below,锚在前)';
590
+ }
591
+ return null;
592
+ }
593
+ /**
594
+ * Strict AI mode. User-pasted legacy text keeps using the compatibility parser,
595
+ * while model output must contain only complete directives. Unknown residual
596
+ * chunks and duplicate directives are errors instead of being swallowed by
597
+ * Peggy's compatibility skipChunk rule.
598
+ */
599
+ export function parseDslStrictWithDiagnostics(dslText) {
600
+ const ops = [];
601
+ const errors = [];
602
+ const rawLines = dslText.split('\n');
603
+ for (let index = 0; index < rawLines.length; index += 1) {
604
+ const line = rawLines[index].trim();
605
+ const lineNo = index + 1;
606
+ if (!line)
607
+ continue;
608
+ // grammar 承诺"Lines starting with # are comments and ignored";strict 比 graceful 严在
609
+ // 拒散文,但**注释行照放行**(与 grammar 一致,也让 AI 输出里夹带的 # 注释不报错)。
610
+ if (line.startsWith('#'))
611
+ continue;
612
+ if (!line.startsWith('[')) {
613
+ errors.push({ line: lineNo, text: line, message: 'unexpected prose or markdown' });
614
+ continue;
615
+ }
616
+ let result;
617
+ try {
618
+ result = parseLine(line, { startRule: 'Line' });
619
+ }
620
+ catch {
621
+ errors.push({ line: lineNo, text: line, message: 'malformed directive' });
622
+ continue;
623
+ }
624
+ if (result === null || result.kind === 'unknown') {
625
+ errors.push({ line: lineNo, text: line, message: 'unrecognized directive' });
626
+ continue;
627
+ }
628
+ const tuples = result.ds.filter(Array.isArray);
629
+ const seen = new Set();
630
+ const duplicate = tuples.find(([tag]) => {
631
+ if (REPEATABLE_DIRECTIVES.has(tag))
632
+ return false; // v8:@code/@quote 可重复(一指令一块/条),不算 dup
633
+ if (seen.has(tag))
634
+ return true;
635
+ seen.add(tag);
636
+ return false;
637
+ });
638
+ if (duplicate) {
639
+ errors.push({ line: lineNo, text: line, message: `duplicate ${duplicate[0]} directive` });
640
+ continue;
641
+ }
642
+ const headerClosed = result.kind === 'arrow'
643
+ ? /^\[arrow(?:\s+#[a-zA-Z0-9_:-]+)?\]/.test(line)
644
+ : new RegExp(`^\\[${result.kind}\\s+#[a-zA-Z0-9_:-]+(?:\\s+create)?\\]`).test(line);
645
+ const allowedClosingChunk = line.startsWith('[arrow]') ? 0 : 1;
646
+ const unknownChunks = result.ds.filter((entry) => entry === null).length;
647
+ // 2026-10-29:skipChunk 语义化后,junk 原文以 string 落 ds(null 只剩 `]` 工件)——
648
+ // strict 的"未知残余"判定同步看 string(null 是 ] 豁免,allowedClosingChunk 管它)
649
+ const junkChunks = result.ds.filter((entry) => typeof entry === 'string').length;
650
+ if (!headerClosed || unknownChunks > allowedClosingChunk || junkChunks > 0) {
651
+ errors.push({ line: lineNo, text: line, message: 'unknown residual text' });
652
+ continue;
653
+ }
654
+ const built = buildOp(result);
655
+ if (built === null) {
656
+ errors.push({ line: lineNo, text: line, message: 'unsupported directive' });
657
+ }
658
+ else if ('op' in built) {
659
+ if (ops.length >= DSL_MAX_OPS) {
660
+ errors.push({ line: lineNo, text: line, message: `元素数已达上限 ${DSL_MAX_OPS},本行及后续被截断` });
661
+ break;
662
+ }
663
+ ops.push(built.op);
664
+ }
665
+ else {
666
+ errors.push({ line: lineNo, text: line, message: built.diag });
667
+ }
668
+ }
669
+ return { ops, errors };
670
+ }
671
+ /**
672
+ * Parse a DSL block (the AI's output) into a list of layout operations.
673
+ *
674
+ * Graceful: unrecognized lines are skipped (no throw). The DSL is
675
+ * intended for AI-to-machine communication — if the AI produces
676
+ * invalid syntax, we silently ignore bad lines.
677
+ *
678
+ * Thin wrapper over {@link parseDslWithDiagnostics}: returns only the ops
679
+ * (the diagnostics are irrelevant for the AI path). Behavior is unchanged.
680
+ */
681
+ export function parseDsl(dslText) {
682
+ return parseDslWithDiagnostics(dslText).ops;
683
+ }