@wscytz/dsl 0.0.0-stage → 0.1.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,47 @@
1
+ /**
2
+ * dsl-relational-solver — 解析后、apply 前,把 relational card op
3
+ * (`right-of`/`below #anchor`)解成绝对坐标 op。纯函数,永不抛错。
4
+ *
5
+ * 【 provenance 】原 apps/web/src/features/ai/relational-solver.ts(B 工程 pilot),
6
+ * 2026-10-24 引擎剥离(execution-intent 第 1 步)入包:解算器是 DSL 语义的一半,
7
+ * 应与 parser 同住,任何宿主(web / CLI / 外部项目)都能把文字解成坐标。
8
+ * 原文件删除,web 侧消费改走本包 barrel。
9
+ *
10
+ * 设计(见 cys-derivative/paper/experiment/b-relational-dsl-pilot-design.md):
11
+ * - 单遍顺序求解:rel op anchor 到**已处理**元素(同批更早 op 的绝对坐标已知,或画布已有的 card)
12
+ * - right-of #X:x = X.x + X.w + gap;y = X.y
13
+ * - below #X:y = X.y + X.h + gap;x = X.x
14
+ * - v9 组级相对锚:right-of @G = 贴组 G 当前已解成员包围盒右缘(y=bbox.minY);
15
+ * below @G = 贴 bbox 下缘(x=bbox.minX)。bbox lazy 求值、顺序敏感(组员先写)。
16
+ * - anchor 不存在(前向引用 / 指向幽灵 id)→ diagnostic(op 保留占位坐标,apply 自己 skip/create)
17
+ * - 环检测:单遍天然防环(anchor 必须已在 geom,前向引用直接 diagnostic)
18
+ * - 默认 card 尺寸 240×120(op.w/h undefined + 非 existing 时);existing card 用其真实 w/h
19
+ *
20
+ * 与 sanitize 协作:applyLayout 入口先 sanitize(修正 size/coord/id)再 solve(rel → 绝对)。
21
+ * diagnostic 复用 SanitizeDiagnostic 形态,apply-layout 合并透出。
22
+ */
23
+ import type { DslOp, DslCardOp } from './dsl-parser.js';
24
+ import type { SanitizeDiagnostic } from './dsl-sanitize.js';
25
+ /** 已知几何(anchor 查找用)。来自画布现有 card 或同批更早 op。
26
+ * v9:group 为可选组名(v9 组级相对锚用;web 侧由 element.meta.group 填充)。 */
27
+ export interface ExistingGeom {
28
+ x: number;
29
+ y: number;
30
+ w: number;
31
+ h: number;
32
+ group?: string;
33
+ }
34
+ export interface SolveResult {
35
+ ops: DslOp[];
36
+ diagnostics: SanitizeDiagnostic[];
37
+ }
38
+ /**
39
+ * 解 relational card op → 绝对坐标 op。
40
+ *
41
+ * @param ops sanitize 后的 ops(含 rel card + 绝对 card + free + arrow)
42
+ * @param existingGeometry 画布现有 card 的 id→几何(seed geom,让 rel 能 anchor 到画布已有 card)
43
+ * @returns { ops: 全绝对坐标 op(rel 已剥离), diagnostics: anchor 缺失等 }
44
+ */
45
+ export declare function solveRelational(ops: DslOp[], existingGeometry?: Map<string, ExistingGeom>): SolveResult;
46
+ /** 仅供测试/类型导出:从 DslCardOp 判定是否 relational(apply 层不直接用)。 */
47
+ export declare function isRelationalCard(op: DslCardOp): boolean;
@@ -0,0 +1,221 @@
1
+ import { DSL_MAX_COORD } from './dsl-sanitize.js';
2
+ /** v9 组级相对锚:求某组**当前已入 geom**成员的包围盒(lazy,求值顺序敏感——
3
+ * 同批更早的 rel 落点也计入,更晚的不回溯;组内无人 → undefined,由调用方产 diagnostic)。
4
+ * broken 占位卡(anchor 缺失 / 组锚失败,坐标无意义)不进 bbox——防 (0,0) 占位
5
+ * 把包围盒拖向画布原点、污染后续组锚落点。 */
6
+ function groupBBox(geom, group, broken) {
7
+ let minX = Infinity;
8
+ let minY = Infinity;
9
+ let maxX = -Infinity;
10
+ let maxY = -Infinity;
11
+ let found = false;
12
+ for (const [id, g] of geom.entries()) {
13
+ if (g.group !== group || broken.has(id))
14
+ continue;
15
+ found = true;
16
+ if (g.x < minX)
17
+ minX = g.x;
18
+ if (g.y < minY)
19
+ minY = g.y;
20
+ if (g.x + g.w > maxX)
21
+ maxX = g.x + g.w;
22
+ if (g.y + g.h > maxY)
23
+ maxY = g.y + g.h;
24
+ }
25
+ return found ? { x: minX, y: minY, w: maxX - minX, h: maxY - minY } : undefined;
26
+ }
27
+ /** card 默认尺寸(与 apply-layout applyCardOp 一致:op.w ?? 240 / op.h ?? 120)。 */
28
+ const DEFAULT_CARD_W = 240;
29
+ const DEFAULT_CARD_H = 120;
30
+ /**
31
+ * 解 relational card op → 绝对坐标 op。
32
+ *
33
+ * @param ops sanitize 后的 ops(含 rel card + 绝对 card + free + arrow)
34
+ * @param existingGeometry 画布现有 card 的 id→几何(seed geom,让 rel 能 anchor 到画布已有 card)
35
+ * @returns { ops: 全绝对坐标 op(rel 已剥离), diagnostics: anchor 缺失等 }
36
+ */
37
+ export function solveRelational(ops, existingGeometry) {
38
+ const diagnostics = [];
39
+ const out = [];
40
+ // seed:画布现有 card 几何(复制,不污染入参)
41
+ const geom = new Map(existingGeometry);
42
+ // anchor 缺失的 card(占位 geom)→ 依赖它的下游 rel 也判 broken,产链式 diagnostic
43
+ // (否则下游会静默解析到占位坐标,用户看到卡在怪位置无信号)
44
+ const brokenAnchors = new Set();
45
+ let i = 0;
46
+ for (const op of ops) {
47
+ const idx = i++;
48
+ if (op.type !== 'card') {
49
+ // free / arrow:透传(非 pilot 关系式范围)
50
+ out.push(op);
51
+ continue;
52
+ }
53
+ const cardId = String(op.cardId);
54
+ // 解析本 card 的几何宽高:op 显式 > existing 真实 > 默认
55
+ const exist = geom.get(cardId);
56
+ const w = op.w ?? exist?.w ?? DEFAULT_CARD_W;
57
+ const h = op.h ?? exist?.h ?? DEFAULT_CARD_H;
58
+ if (op.rel) {
59
+ // v9:anchor 双形态 —— #id(单卡 geom 条目)/ @组名(组包围盒,lazy 求当前已解成员;
60
+ // broken 占位不进 bbox,见 groupBBox)
61
+ const isGroup = op.rel.anchorGroup === true;
62
+ const anchorRect = isGroup
63
+ ? groupBBox(geom, op.rel.anchor, brokenAnchors)
64
+ : geom.get(op.rel.anchor);
65
+ if (!anchorRect) {
66
+ // anchor 不存在(前向引用 / 幽灵 id / 组锚无成员)→ diagnostic,op 保留占位坐标
67
+ diagnostics.push({
68
+ opIndex: idx,
69
+ message: isGroup
70
+ ? `relational card #${cardId} 的组锚 @${op.rel.anchor} 无已解成员(组不存在,或组员都在本卡之后——组锚按书写顺序解)`
71
+ : `relational card #${cardId} 的 anchor #${op.rel.anchor} 不存在(画布/同批更早均无)`,
72
+ });
73
+ // 标 broken:下游 anchor 到本卡的 rel 也会得链式 diagnostic(否则静默解析到占位)
74
+ brokenAnchors.add(cardId);
75
+ // 剥离 rel,保留占位 x/y(apply 自己 create/skip)
76
+ // v9:group 继承 —— 同批更新既有卡不带 @group 时沿用 existing 组名(与 web
77
+ // applyCardMeta 的"不带即保留"语义一致);空串显式清组不受影响。
78
+ const { rel: _rel, ...rest } = op;
79
+ out.push({ ...rest, w, h });
80
+ geom.set(cardId, { x: op.x, y: op.y, w, h, group: op.group ?? exist?.group });
81
+ continue;
82
+ }
83
+ // 传递性断链:anchor 存在但本身是 broken 占位 → 本卡也判 broken + diagnostic(链式传播)
84
+ // (仅卡锚;组锚无 broken 概念——组内成员各自携带自己的诊断)
85
+ if (!isGroup && brokenAnchors.has(op.rel.anchor)) {
86
+ diagnostics.push({
87
+ opIndex: idx,
88
+ message: `relational card #${cardId} 的 anchor #${op.rel.anchor} 上游 anchor 缺失(链式占位,坐标可能不符预期)`,
89
+ });
90
+ brokenAnchors.add(cardId);
91
+ }
92
+ const gap = op.rel.gap;
93
+ // 统一放置公式(组锚即包围盒矩形):right-of → (a.x+a.w+gap, a.y 顶对齐);
94
+ // below → (a.x 左对齐, a.y+a.h+gap)。组右=贴 bbox 右缘、组下=贴 bbox 下缘。
95
+ const ax = op.rel.dir === 'right-of' ? anchorRect.x + anchorRect.w + gap : anchorRect.x;
96
+ const ay = op.rel.dir === 'below' ? anchorRect.y + anchorRect.h + gap : anchorRect.y;
97
+ // 碰撞避让:沿关系轴(below→y / right-of→x)推开已置 card,消除参照系碰撞
98
+ const { x, y } = resolveCollision(ax, ay, w, h, op.rel.dir === 'below' ? 'y' : 'x', geom, gap, cardId);
99
+ const { rel: _rel, ...rest } = op;
100
+ out.push({ ...rest, x, y, w, h });
101
+ geom.set(cardId, { x, y, w, h, group: op.group ?? exist?.group });
102
+ continue;
103
+ }
104
+ // 绝对 card:记录几何(供后续 rel anchor),op 原样返回(引用稳定)
105
+ // v5(E) keepExistingPos:op.x/y 是 parser 占位 (0,0),必须沿用 existing 真实坐标,
106
+ // 否则下游 rel anchor 到本卡会锚到 (0,0) 飞画布左上角。
107
+ if (op.keepExistingPos === true && exist) {
108
+ geom.set(cardId, { x: exist.x, y: exist.y, w, h, group: op.group ?? exist.group });
109
+ }
110
+ else {
111
+ geom.set(cardId, { x: op.x, y: op.y, w, h, group: op.group ?? exist?.group });
112
+ }
113
+ out.push(op);
114
+ }
115
+ const validated = validateSolvedCoordinates(out, existingGeometry);
116
+ return {
117
+ ops: validated.ops,
118
+ diagnostics: [...diagnostics, ...validated.diagnostics],
119
+ };
120
+ }
121
+ /** 仅供测试/类型导出:从 DslCardOp 判定是否 relational(apply 层不直接用)。 */
122
+ export function isRelationalCard(op) {
123
+ return op.rel !== undefined;
124
+ }
125
+ /** AABB 相交(严格 >0 重叠面积)。纯函数,永不抛错。 */
126
+ function rectsOverlap(a, b) {
127
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
128
+ }
129
+ /**
130
+ * 碰撞避让:card (ax,ay,w,h) 若与 geom 里任何已置 card 相交,沿 axis 单向推进越过障碍,
131
+ * 直到无相交(单调有界,每轮至少越过一张,≤ geom.size 轮收敛)。
132
+ *
133
+ * 为什么沿「关系自己的轴」:below 的 x 由 anchor 固定(列归属)→ 推 y 守列;
134
+ * right-of 的 y 由 anchor 固定(行归属)→ 推 x 守行。语义最保真。
135
+ * clearance = 该 card 的 @gap(与 anchor 间距一致,避让后视觉间距均匀)。
136
+ *
137
+ * 单遍不破依赖:anchor 在 op 序更早 → 避让时所有相关 card 已最终落位;后续 right-of/below
138
+ * 本 card 的 op 用本 card 避让后的最终 geom 派生,自动跟随,无需级联。
139
+ */
140
+ function resolveCollision(ax, ay, w, h, axis, geom, clearance, selfId) {
141
+ // clearance 钳 ≥0:负 @gap(parser 接受负数)会让推进推不过障碍(新坐标仍相交)→ 残留重叠。
142
+ // 钳 0 后负 gap 退化为"卡片贴合"(clearance=0,严格 >0 不算重叠),避让仍保证清空。
143
+ const clr = Math.max(0, clearance);
144
+ // O(K log K) 排序单扫,替代原 O(N²) 多轮(每轮扫全 geom,最坏 N 轮)。
145
+ // 等价性:沿关系轴推进 = 跳过所有「垂直轴区间重叠 + 关系轴区间相交」的已置 card。
146
+ // 先筛垂直轴重叠集(只有它们可能在关系轴相撞),再按关系轴坐标升序单次扫描:
147
+ // 遇相交则推过其尾 + clr。cur 只增 → 已扫过的(更小坐标)不会被新位置再相交
148
+ // → 一遍收敛,与原「多轮取 max」同果(已手验相邻/间隔/负 gap 多 case)。
149
+ if (axis === 'y') {
150
+ // below:x 由 anchor 固定(列归属)→ 沿 y 推。筛 x 区间重叠的障碍,按 y 升序。
151
+ const hit = [];
152
+ for (const [id, g] of geom) {
153
+ if (id === selfId)
154
+ continue;
155
+ if (ax < g.x + g.w && ax + w > g.x)
156
+ hit.push(g);
157
+ }
158
+ hit.sort((a, b) => a.y - b.y);
159
+ let y = ay;
160
+ for (const g of hit) {
161
+ if (y < g.y + g.h && g.y < y + h)
162
+ y = g.y + g.h + clr;
163
+ }
164
+ return { x: ax, y };
165
+ }
166
+ // right-of:y 由 anchor 固定(行归属)→ 沿 x 推。筛 y 区间重叠的障碍,按 x 升序。
167
+ const hit = [];
168
+ for (const [id, g] of geom) {
169
+ if (id === selfId)
170
+ continue;
171
+ if (ay < g.y + g.h && ay + h > g.y)
172
+ hit.push(g);
173
+ }
174
+ hit.sort((a, b) => a.x - b.x);
175
+ let x = ax;
176
+ for (const g of hit) {
177
+ if (x < g.x + g.w && g.x < x + w)
178
+ x = g.x + g.w + clr;
179
+ }
180
+ return { x, y: ay };
181
+ }
182
+ /**
183
+ * solve 后的第二道坐标门。关系偏移和碰撞推进都可能把已 sanitize 的输入重新推到
184
+ * 边界外;这里钳回统一坐标域,并在钳位造成重叠时明确诊断。
185
+ */
186
+ function validateSolvedCoordinates(ops, existingGeometry) {
187
+ const diagnostics = [];
188
+ const geom = new Map(existingGeometry);
189
+ const validated = ops.map((op, opIndex) => {
190
+ if (op.type !== 'card')
191
+ return op;
192
+ const id = String(op.cardId);
193
+ const existing = geom.get(id);
194
+ const w = op.w ?? existing?.w ?? DEFAULT_CARD_W;
195
+ const h = op.h ?? existing?.h ?? DEFAULT_CARD_H;
196
+ const x = Math.min(DSL_MAX_COORD, Math.max(-DSL_MAX_COORD, op.x));
197
+ const y = Math.min(DSL_MAX_COORD, Math.max(-DSL_MAX_COORD, op.y));
198
+ const changed = x !== op.x || y !== op.y;
199
+ const next = { x, y, w, h };
200
+ if (changed) {
201
+ diagnostics.push({
202
+ opIndex,
203
+ message: `relational card #${id} 的坐标已限制到 +/-${DSL_MAX_COORD} 边界`,
204
+ });
205
+ for (const [otherId, other] of geom) {
206
+ if (otherId === id)
207
+ continue;
208
+ if (rectsOverlap(next, other)) {
209
+ diagnostics.push({
210
+ opIndex,
211
+ message: `card #${id} 在坐标边界钳位后与 #${otherId} 碰撞`,
212
+ });
213
+ break;
214
+ }
215
+ }
216
+ }
217
+ geom.set(id, next);
218
+ return changed ? { ...op, x, y } : op;
219
+ });
220
+ return { ops: validated, diagnostics };
221
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * DSL Sanitize 层 —— 解析后、apply 前,纯函数修正 LLM 常见错误 + 产 diagnostic。
3
+ *
4
+ * 学 tldraw agent sanitization(corrects non-existent IDs / ensures unique IDs /
5
+ * normalizes coordinates):不指望 LLM 算对,用引擎层兜底。
6
+ *
7
+ * 契约(不可破):
8
+ * - 纯函数,永不抛错(继承 dsl-robustness.test.ts 的 36 测试契约)
9
+ * - 合法 op 原样返回(引用稳定 —— sanitize 是 opt-in 修正,不改合规输入)
10
+ * - free arrow 的 w/h 不动(负值编码方向,dsl-parser.ts:143)
11
+ * - 不破坏 roundtrip byte-equal(dsl-e2e-roundtrip.test.ts):合法小卡(如 10×10)原样往返
12
+ * - 无 ctx 时不产 diagnostic(case 6 等不依赖 ctx 的路径仍可用)
13
+ *
14
+ * 接入:apply-layout.ts 的 applyLayout 入口(所有 5+ 调用点必经,零调用方改)。
15
+ * case 清单见 cys-stift-docs/docs/specs/2026-07-07-dsl-sanitize-layer-draft.md。
16
+ * 当前实现:case 6(非法 size)+ case 1/11(card 不存在 diagnostic)+ case 7(arrow 端点 diagnostic)
17
+ * + case 3(free op 跨 kind 告警)。
18
+ */
19
+ import type { DslOp } from './dsl-parser.js';
20
+ /** Sanitize 诊断:亮给用户/AI 看(让 AgentConfirmCard/dsl-dialog 把"引用了不存在的卡 #X"反馈出来)。 */
21
+ export type SanitizeDiagnostic = {
22
+ opIndex: number;
23
+ message: string;
24
+ };
25
+ export interface SanitizeResult {
26
+ ops: DslOp[];
27
+ diagnostics: SanitizeDiagnostic[];
28
+ }
29
+ /** 上下文:画布上已有的 card/free id(case 1/11/7 判断 id 是否存在用)。可选 —— 无 ctx 时不产 diagnostic。
30
+ * existingFreeKinds(case 3 跨 kind 告警):host 的 free 元素 id → kind(rect/text/frame),
31
+ * 用来检测 free op 带了 host 已有但 kind 不同的 id(apply 会 mint 新 id 而非更新)。 */
32
+ export interface SanitizeCtx {
33
+ existingCardIds?: Set<string>;
34
+ existingFreeIds?: Set<string>;
35
+ existingFreeKinds?: Map<string, 'rect' | 'text' | 'frame'>;
36
+ }
37
+ /** card/rect/frame 的 w/h MAX 上限(防 LLM 生成超大跑出可视区)。
38
+ * 不设 MIN 下界 —— 合法小卡(如 10×10)必须原样往返(commit fac88bb 负坐标契约的同理)。 */
39
+ export declare const DSL_MAX_SIZE = 2000;
40
+ export declare const DSL_MAX_GAP = 2000;
41
+ /** v5:@title/@content 长度截断共用 dsl-grammar 的 truncateDslText(代理对安全;G/H)——
42
+ * parser 已截断,sanitize 是 apply 前第二道防线(幂等,合规输入原样)。 */
43
+ /** x/y 坐标 MAX 绝对值(防 LLM 生成 1e6 跑出可视区)。保负向 —— 负坐标合法(画布 pan 允许,
44
+ * commit fac88bb 契约),只钳极端有限值,不钳合理负值(如 -100)。 */
45
+ export declare const DSL_MAX_COORD = 10000;
46
+ /**
47
+ * Sanitize 一批 DSL ops。纯函数,永不抛错。
48
+ *
49
+ * 单个 op sanitize 失败(异常或未知类型)→ 原样保留,让 apply 自己 skip。
50
+ * 保守策略:宁可让 apply 阶段 diagnostic,也不在 sanitize 丢数据。
51
+ *
52
+ * @param ops parseDslWithDiagnostics 输出的 ops
53
+ * @param ctx 画布现有 id(case 1/11/7 diagnostic 用;可选,无则不产 diagnostic)
54
+ * @returns { ops: 修正后的 ops, diagnostics: 诊断 }
55
+ */
56
+ export declare function sanitizeDslOps(ops: DslOp[], ctx?: SanitizeCtx): SanitizeResult;
@@ -0,0 +1,257 @@
1
+ import { 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_CARD_TYPES, DSL_RESERVED_GROUP_ANCHOR_WORDS, truncateDslText, } from './dsl-grammar.js';
2
+ /** card/rect/frame 的 w/h MAX 上限(防 LLM 生成超大跑出可视区)。
3
+ * 不设 MIN 下界 —— 合法小卡(如 10×10)必须原样往返(commit fac88bb 负坐标契约的同理)。 */
4
+ export const DSL_MAX_SIZE = 2000;
5
+ export const DSL_MAX_GAP = 2000;
6
+ /** w/h sanitize:非正(≤0)/非有限 → undefined(让 apply 用 shape 默认 240×120 等);
7
+ * 超大 → MAX;合法正数(含小卡)原样保留。 */
8
+ function sanitizeSize(n) {
9
+ if (n === undefined)
10
+ return undefined;
11
+ if (!Number.isFinite(n) || n <= 0)
12
+ return undefined;
13
+ return Math.min(DSL_MAX_SIZE, n);
14
+ }
15
+ /** v5:@title/@content 长度截断共用 dsl-grammar 的 truncateDslText(代理对安全;G/H)——
16
+ * parser 已截断,sanitize 是 apply 前第二道防线(幂等,合规输入原样)。 */
17
+ /** x/y 坐标 MAX 绝对值(防 LLM 生成 1e6 跑出可视区)。保负向 —— 负坐标合法(画布 pan 允许,
18
+ * commit fac88bb 契约),只钳极端有限值,不钳合理负值(如 -100)。 */
19
+ export const DSL_MAX_COORD = 10000;
20
+ /** 坐标钳位:n 已是有限数(parse POS_RE 保证) → 钳 [-MAX_COORD, MAX_COORD];防御性防非有限(→0)。 */
21
+ function clampCoord(n) {
22
+ if (!Number.isFinite(n))
23
+ return 0;
24
+ return Math.min(DSL_MAX_COORD, Math.max(-DSL_MAX_COORD, n));
25
+ }
26
+ /** card op:case 6(size)+ case 1/11(id 不存在)+ case 2b(create id 冲突预检)diagnostic。 */
27
+ function sanitizeCard(op, idx, ctx, diagnostics) {
28
+ // case 1+11:无 create flag + id 不在 existingCardIds → diagnostic
29
+ // (apply 会 skip,sanitize 额外提示让用户/AI 知道"引用了不存在的卡,想新建加 create")
30
+ if (ctx?.existingCardIds !== undefined && !op.create && !ctx.existingCardIds.has(String(op.cardId))) {
31
+ diagnostics.push({
32
+ opIndex: idx,
33
+ message: `card #${op.cardId} 不存在于画布(若想新建需加 create 标记)`,
34
+ });
35
+ }
36
+ // create flag + id 已存在 → plan 阶段跳过,不把 create 偷换成 update。
37
+ if (op.create && ctx?.existingCardIds !== undefined && ctx.existingCardIds.has(String(op.cardId))) {
38
+ diagnostics.push({
39
+ opIndex: idx,
40
+ message: `create card #${op.cardId} id 已存在(冲突,建卡将失败)`,
41
+ });
42
+ }
43
+ // 关系间距必须非负且有上限。solver 会沿关系轴累加 gap,所以它与坐标本身
44
+ // 一样属于输入边界,不能等 solve 后才处理。
45
+ let rel = op.rel;
46
+ if (op.rel) {
47
+ // v9:锚位裸名与内建指令同名(grammar 故意放行以保精确报错,见 dsl.peggy groupRef)→
48
+ // 诊断 + 剥 rel。引号形式 @"gap" 是显式意图,不受此限。
49
+ if (op.rel.anchorGroup === true &&
50
+ op.rel.anchorQuoted !== true &&
51
+ DSL_RESERVED_GROUP_ANCHOR_WORDS.includes(op.rel.anchor)) {
52
+ diagnostics.push({
53
+ opIndex: idx,
54
+ message: `card #${op.cardId} 的组锚 @${op.rel.anchor} 与内建指令同名,已剥除——组名避开指令词;确有同名组用 @"${op.rel.anchor}" 引用;组间距写在锚后(如 right-of @组名 @gap(30))`,
55
+ });
56
+ rel = undefined;
57
+ }
58
+ else {
59
+ const gap = Number.isFinite(op.rel.gap)
60
+ ? Math.min(DSL_MAX_GAP, Math.max(0, op.rel.gap))
61
+ : 0;
62
+ if (gap !== op.rel.gap) {
63
+ diagnostics.push({
64
+ opIndex: idx,
65
+ message: `card #${op.cardId} 的 gap 已限制到 0..${DSL_MAX_GAP}`,
66
+ });
67
+ rel = { ...op.rel, gap };
68
+ }
69
+ }
70
+ }
71
+ // case 6:size 修正 + case 5:坐标钳位 + v5:title/content 截断
72
+ const w = sanitizeSize(op.w);
73
+ const h = sanitizeSize(op.h);
74
+ const x = clampCoord(op.x);
75
+ const y = clampCoord(op.y);
76
+ const title = op.title !== undefined ? truncateDslText(op.title, DSL_MAX_TEXT_LEN) : op.title;
77
+ const content = op.content !== undefined ? truncateDslText(op.content, DSL_MAX_CONTENT_LEN) : op.content;
78
+ // v7:group 二次截断 + href 二次截断到上限(幂等 —— parser 已截,这里防程序构造 op 绕过)。
79
+ // href.slice 仅在超长时发生,否则保引用(不破坏合规输入引用稳定 / round-trip byte-equal)。
80
+ const group = op.group !== undefined ? truncateDslText(op.group, DSL_MAX_TEXT_LEN) : op.group;
81
+ const href = op.href !== undefined && op.href.length > DSL_MAX_HREF_TARGETS
82
+ ? op.href.slice(0, DSL_MAX_HREF_TARGETS)
83
+ : op.href;
84
+ // v8:结构化字段二次防线(幂等 —— parser 已截/校验,这里防程序构造 op 绕过)。
85
+ // 引用稳定:仅在真被改时才产新数组/对象,合规输入原样(不破 round-trip byte-equal)。
86
+ const cardType = op.cardType !== undefined && !DSL_CARD_TYPES.includes(op.cardType)
87
+ ? undefined
88
+ : op.cardType;
89
+ const tags = op.tags !== undefined && op.tags.length > DSL_MAX_TAG_COUNT
90
+ ? op.tags.slice(0, DSL_MAX_TAG_COUNT)
91
+ : op.tags;
92
+ const links = op.links !== undefined && op.links.length > DSL_MAX_LINK_COUNT
93
+ ? op.links.slice(0, DSL_MAX_LINK_COUNT)
94
+ : op.links;
95
+ let code = op.code;
96
+ if (code !== undefined) {
97
+ const capped = code.length > DSL_MAX_CODE_BLOCKS ? code.slice(0, DSL_MAX_CODE_BLOCKS) : code;
98
+ let changed = capped !== code;
99
+ const mapped = capped.map((b) => {
100
+ const c = truncateDslText(b.code, DSL_MAX_CONTENT_LEN);
101
+ if (c !== b.code) {
102
+ changed = true;
103
+ return { ...b, code: c };
104
+ }
105
+ return b;
106
+ });
107
+ code = changed ? mapped : op.code;
108
+ }
109
+ let quotes = op.quotes;
110
+ if (quotes !== undefined) {
111
+ const capped = quotes.length > DSL_MAX_QUOTES ? quotes.slice(0, DSL_MAX_QUOTES) : quotes;
112
+ let changed = capped !== quotes;
113
+ const mapped = capped.map((q) => {
114
+ const t = truncateDslText(q.text, DSL_MAX_CONTENT_LEN);
115
+ if (t !== q.text) {
116
+ changed = true;
117
+ return { ...q, text: t };
118
+ }
119
+ return q;
120
+ });
121
+ quotes = changed ? mapped : op.quotes;
122
+ }
123
+ if (w === op.w && h === op.h && x === op.x && y === op.y && rel === op.rel &&
124
+ title === op.title && content === op.content && group === op.group && href === op.href &&
125
+ cardType === op.cardType && tags === op.tags && links === op.links && code === op.code && quotes === op.quotes) {
126
+ return op;
127
+ }
128
+ return { ...op, w, h, x, y, rel, title, content, group, href, cardType, tags, links, code, quotes };
129
+ }
130
+ /** free shape(rect/text/frame)op:case 6(size)+ case 3(跨 kind 告警)。
131
+ * case 3:op.id 命中 host 已有但 kind 不同的元素 → diagnostic(apply 会 mint 新 id,提示用户/AI)。 */
132
+ function sanitizeFree(op, idx, ctx, diagnostics) {
133
+ // case 3:跨 kind 告警(op shape ≠ host 已有同 id 元素的 kind)
134
+ // applyFreeOp update 路径有 kind 守卫 → 不命中 → create 路径 mint uid(不覆盖,安全);
135
+ // sanitize 额外产 diagnostic 让用户/AI 知道 id 被复用了。
136
+ if (op.id && ctx?.existingFreeKinds !== undefined) {
137
+ const hostKind = ctx.existingFreeKinds.get(op.id);
138
+ if (hostKind !== undefined && hostKind !== op.shape) {
139
+ diagnostics.push({
140
+ opIndex: idx,
141
+ message: `${op.shape} #${op.id} 与已有 ${hostKind} 跨 kind,将新建而非更新`,
142
+ });
143
+ }
144
+ }
145
+ // case 6:size 修正 + case 5:坐标钳位
146
+ const w = sanitizeSize(op.w);
147
+ const h = sanitizeSize(op.h);
148
+ const x = clampCoord(op.x);
149
+ const y = clampCoord(op.y);
150
+ // v7:group 二次截断(幂等;compute 仅 text,parser 已截断,这里不重复以避联合类型分支)。
151
+ const group = op.group !== undefined ? truncateDslText(op.group, DSL_MAX_TEXT_LEN) : op.group;
152
+ if (w === op.w && h === op.h && x === op.x && y === op.y && group === op.group)
153
+ return op;
154
+ return { ...op, w, h, x, y, group };
155
+ }
156
+ /** arrow op:case 7(端点不存在 diagnostic)。free arrow 不检查(无端点)。
157
+ * case 4(端点重写)待后续。 */
158
+ function sanitizeArrow(op, idx, existingIds, diagnostics) {
159
+ const isFreeArrow = op.freeArrow || (!op.from && !op.to);
160
+ if (!isFreeArrow && existingIds !== undefined) {
161
+ if (op.from && !existingIds.has(op.from)) {
162
+ diagnostics.push({ opIndex: idx, message: `arrow from #${op.from} 不存在于画布` });
163
+ }
164
+ if (op.to && !existingIds.has(op.to)) {
165
+ diagnostics.push({ opIndex: idx, message: `arrow to #${op.to} 不存在于画布` });
166
+ }
167
+ }
168
+ // case 5:free arrow x/y 钳位(位置);curve 控制点 / elbow 折点 同为页坐标,一并钳
169
+ // (同 case-5 防 LLM 生成超大跑出可视区;applyArrowOp 原样写 host 无二次钳)。
170
+ // w/h 不动(负值编码方向)。关系箭头无 x/y/curve/elbow(undefined 透传)。
171
+ const x = op.x !== undefined ? clampCoord(op.x) : undefined;
172
+ const y = op.y !== undefined ? clampCoord(op.y) : undefined;
173
+ const curve = op.curve !== undefined ? { cx: clampCoord(op.curve.cx), cy: clampCoord(op.curve.cy) } : undefined;
174
+ const elbow = op.elbow !== undefined ? op.elbow.map((p) => ({ x: clampCoord(p.x), y: clampCoord(p.y) })) : undefined;
175
+ // 引用稳定:仅在某值实际被钳时才算改(合法 curve/elbow 原样往返 byte-equal)。
176
+ // NaN 经 clampCoord→0,0!==NaN 为 true → 必判为改(顺带修非有限 curve/elbow)。
177
+ const curveChanged = op.curve !== undefined && (curve.cx !== op.curve.cx || curve.cy !== op.curve.cy);
178
+ const elbowChanged = op.elbow !== undefined &&
179
+ op.elbow.some((p, i) => elbow[i].x !== p.x || elbow[i].y !== p.y);
180
+ if (x === op.x && y === op.y && !curveChanged && !elbowChanged)
181
+ return op;
182
+ return { ...op, x, y, curve, elbow };
183
+ }
184
+ /**
185
+ * Sanitize 一批 DSL ops。纯函数,永不抛错。
186
+ *
187
+ * 单个 op sanitize 失败(异常或未知类型)→ 原样保留,让 apply 自己 skip。
188
+ * 保守策略:宁可让 apply 阶段 diagnostic,也不在 sanitize 丢数据。
189
+ *
190
+ * @param ops parseDslWithDiagnostics 输出的 ops
191
+ * @param ctx 画布现有 id(case 1/11/7 diagnostic 用;可选,无则不产 diagnostic)
192
+ * @returns { ops: 修正后的 ops, diagnostics: 诊断 }
193
+ */
194
+ export function sanitizeDslOps(ops, ctx) {
195
+ const diagnostics = [];
196
+ const out = [];
197
+ // 永不抛错契约:非数组入参(null/undefined/非数组,仅可能由 as any 误传)→ 当空,不抛。
198
+ const src = Array.isArray(ops) ? ops : [];
199
+ // 预合并 existingIds(arrow 端点检查用 —— 端点可能是 card 或 free 元素)
200
+ let existingIds;
201
+ if (ctx?.existingCardIds || ctx?.existingFreeIds) {
202
+ existingIds = new Set();
203
+ ctx.existingCardIds?.forEach((id) => existingIds.add(id));
204
+ ctx.existingFreeIds?.forEach((id) => existingIds.add(id));
205
+ }
206
+ // Same-batch declarations are valid arrow endpoints. Persistence may still
207
+ // fail later; commitApplyPlan handles that dependency failure explicitly.
208
+ if (existingIds) {
209
+ for (const op of src) {
210
+ if (op.type === 'card' && op.create)
211
+ existingIds.add(String(op.cardId));
212
+ if (op.type === 'free' && op.id)
213
+ existingIds.add(op.id);
214
+ }
215
+ }
216
+ let i = 0;
217
+ // 重复 id 探测(card/free 同名空间):下游 geom 按 id 覆盖 → 后者静默覆盖前者。
218
+ // 以前零信号(两个 #c1 无声合并);现记 diagnostic,不丢数据(保守策略不变)。
219
+ const seenIds = new Set();
220
+ for (const op of src) {
221
+ const idx = i++;
222
+ try {
223
+ switch (op.type) {
224
+ case 'card':
225
+ if (op.cardId != null) {
226
+ const id = String(op.cardId);
227
+ if (seenIds.has(id)) {
228
+ diagnostics.push({ opIndex: idx, message: `duplicate element id #${id} — later op overrides earlier` });
229
+ }
230
+ seenIds.add(id);
231
+ }
232
+ out.push(sanitizeCard(op, idx, ctx, diagnostics));
233
+ break;
234
+ case 'free':
235
+ if (op.id != null) {
236
+ if (seenIds.has(op.id)) {
237
+ diagnostics.push({ opIndex: idx, message: `duplicate element id #${op.id} — later op overrides earlier` });
238
+ }
239
+ seenIds.add(op.id);
240
+ }
241
+ out.push(sanitizeFree(op, idx, ctx, diagnostics));
242
+ break;
243
+ case 'arrow':
244
+ out.push(sanitizeArrow(op, idx, existingIds, diagnostics));
245
+ break;
246
+ default:
247
+ // 未知类型:原样保留(保守,让 apply 自己处理)
248
+ out.push(op);
249
+ }
250
+ }
251
+ catch {
252
+ // 永不抛错契约:单个 op sanitize 失败 → 原样保留
253
+ out.push(op);
254
+ }
255
+ }
256
+ return { ops: out, diagnostics };
257
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @wscytz/dsl — cys-dsl 文本格式包(画布的双向文字表示 / 转义)。
3
+ *
4
+ * 公开 API:grammar 单一源(DSL_VERSION / KINDS / COLORS / GRAMMAR_REFERENCE)+
5
+ * serializer(serializeCanvas / serializeCanvasReadable / serializeElement)+
6
+ * Peggy parser(parseDsl / parseDslWithDiagnostics / parseDslStrictWithDiagnostics)+
7
+ * sanitize(sanitizeDslOps,防 LLM 产非法值不崩的 opt-in 修正层)+
8
+ * relational solver(solveRelational,right-of/below #anchor → 绝对坐标)+
9
+ * JSON Canvas 适配器(toJsonCanvas,cys-dsl → Obsidian .canvas 等通用格式)+
10
+ * 边界类型(types.ts,包内自持的结构性复制,compat 测试钉住)。
11
+ *
12
+ * 依赖:**零运行时依赖**——边界类型包内自持(2026-10-24 引擎剥离,execution-intent 第 1 步;
13
+ * 原依赖 canvas-engine/domain 仅为类型,已由 types.ts 结构性复制替代,devDependencies 保留
14
+ * 供 compat 测试比对)。纯逻辑,框架无关。
15
+ * freedraw 不在 DSL(程序自管 R2 + 渲染)。详细见仓库 README。
16
+ * 注意:cli.ts(node:fs)不进 barrel——CLI 经 `pnpm --filter @wscytz/dsl cli` 跑。
17
+ */
18
+ export * from './dsl-grammar.js';
19
+ export * from './canvas-dsl.js';
20
+ export * from './dsl-parser.js';
21
+ export * from './dsl-sanitize.js';
22
+ export * from './dsl-compute.js';
23
+ export * from './dsl-relational-solver.js';
24
+ export * from './to-json-canvas.js';
25
+ export * from './to-svg.js';
26
+ export * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @wscytz/dsl — cys-dsl 文本格式包(画布的双向文字表示 / 转义)。
3
+ *
4
+ * 公开 API:grammar 单一源(DSL_VERSION / KINDS / COLORS / GRAMMAR_REFERENCE)+
5
+ * serializer(serializeCanvas / serializeCanvasReadable / serializeElement)+
6
+ * Peggy parser(parseDsl / parseDslWithDiagnostics / parseDslStrictWithDiagnostics)+
7
+ * sanitize(sanitizeDslOps,防 LLM 产非法值不崩的 opt-in 修正层)+
8
+ * relational solver(solveRelational,right-of/below #anchor → 绝对坐标)+
9
+ * JSON Canvas 适配器(toJsonCanvas,cys-dsl → Obsidian .canvas 等通用格式)+
10
+ * 边界类型(types.ts,包内自持的结构性复制,compat 测试钉住)。
11
+ *
12
+ * 依赖:**零运行时依赖**——边界类型包内自持(2026-10-24 引擎剥离,execution-intent 第 1 步;
13
+ * 原依赖 canvas-engine/domain 仅为类型,已由 types.ts 结构性复制替代,devDependencies 保留
14
+ * 供 compat 测试比对)。纯逻辑,框架无关。
15
+ * freedraw 不在 DSL(程序自管 R2 + 渲染)。详细见仓库 README。
16
+ * 注意:cli.ts(node:fs)不进 barrel——CLI 经 `pnpm --filter @wscytz/dsl cli` 跑。
17
+ */
18
+ export * from './dsl-grammar.js';
19
+ export * from './canvas-dsl.js';
20
+ export * from './dsl-parser.js';
21
+ export * from './dsl-sanitize.js';
22
+ export * from './dsl-compute.js';
23
+ export * from './dsl-relational-solver.js';
24
+ export * from './to-json-canvas.js';
25
+ export * from './to-svg.js';
26
+ export * from './types.js';