// revise 漂移对账闸门:确保"只改指令说的,别的一个字不动"。 // // 问题(doc/soma 功能强化.md §2.1):revise 模式下产物是**完整**新声明,静态闸门只 // 检查产物自身合法,检查不出"模型顺手把没让它改的文案/数值/绑定改了"——用户以为 // 只换了个按钮,实际数据被悄悄改掉。这类漂移比语法错误更危险(它能过闸门直达线上)。 // // 保守策略:模型必须在 notes 首行声明它动了哪些锚点(revise_scope),程序据此对账: // 声明范围内的差异 = 正常修改;范围外的任何差异 = 漂移,进 issues 回喂重来。 // 模型不给 revise_scope 也算失败(强制它自述改动范围,顺带提升它的自我约束)。 import type { PageDeclarations } from './PageDeclarationValidator'; import { compilePageDeclaration } from 'soma-core/server/page-declaration'; /** 模型自述的修改范围 */ export interface ReviseScope { touched: string[]; added: string[]; removed: string[]; } const SCOPE_LINE = /revise_scope\s*[:=]?\s*(\{[\s\S]*?\})/; /** 从 notes 里解析 revise_scope(缺失或非法返回 null) */ export function parseReviseScope(notes: string | undefined): ReviseScope | null { if (!notes) { return null; } const match = SCOPE_LINE.exec(notes); if (!match) { return null; } try { const raw = JSON.parse(match[1]) as Partial>; const toList = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; return { touched: toList(raw.touched), added: toList(raw.added), removed: toList(raw.removed), }; } catch { return null; } } /** 声明的可比较特征集 */ interface DeclarationFeatures { /** initProps 键 → 绑定它的锚点集合(数据归属于锚点:改锚点即可改它的数据) */ dataOwners: Map>; /** html 中的锚点 id 集合 */ anchors: Set; /** 锚点 → 绑定声明(show 值序列化后) */ bindings: Map; /** initProps 键 → 值(序列化) */ initProps: Map; /** 事件行集合(原文去空白) */ events: Set; } function collectAnchors(html: string): Set { const out = new Set(); for (const match of html.matchAll(/<[a-z][^>]*>/gi)) { const id = readHtmlAttribute(match[0], 'data-soma-id'); if (id) out.add(id); } return out; } /** * 声明 HTML 的属性允许三种合法写法。按完整标签读取,避免组件宿主、slot template * 或普通锚点只因用了单引号/无引号就从 revise 对账中消失。 */ function readHtmlAttribute(tag: string, name: string): string | undefined { const match = tag.match(new RegExp( `\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`, 'i', )); return match ? (match[1] ?? match[2] ?? match[3]) : undefined; } function stableStringify(value: unknown): string { if (value === null || typeof value !== 'object') { return JSON.stringify(value) ?? 'null'; } if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]`; } const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b), ); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`; } /** 递归收集 show 树:锚点 → 绑定声明(嵌套作用域用 "父>子" 记名) */ function collectBindings( show: unknown, prefix: string, out: Map, ): void { if (!show || typeof show !== 'object') { return; } for (const [key, value] of Object.entries(show as Record)) { const anchorKey = prefix ? `${prefix}>${key}` : key; if (value && typeof value === 'object' && 'show' in (value as Record)) { const nested = value as Record; const { show: childShow, ...rest } = nested; out.set(anchorKey, stableStringify(rest)); collectBindings(childShow, anchorKey, out); continue; } out.set(anchorKey, stableStringify(value)); } } function collectFeatures(decl: PageDeclarations): DeclarationFeatures { const bindings = new Map(); const initProps = new Map(); let compiled: ReturnType | null = null; try { // soma-ai 不负责装配 Host 的 OOUI 引擎。编译失败时本函数按既有策略退回 // 声明层对账;Host 若提交带 ooui 的旧声明,应在 Host 启动边界装配它。 compiled = compilePageDeclaration(decl); } catch { compiled = null; } collectBindings(compiled?.uiConfig.show, '', bindings); const rawInit = compiled?.uiConfig.initProps; if (rawInit && typeof rawInit === 'object') { for (const [key, value] of Object.entries(rawInit as Record)) { initProps.set(key, stableStringify(value)); } } const events = new Set( (compiled?.eventConfig ?? []).map((event) => `${event.somaId}.${event.domEvent}${event.keyModifier ? `.${event.keyModifier}` : ''}=${stableStringify(event)}`), ); // 数据归属:show 里谁引用了 page.X,X 就归谁——申报改了该锚点,它的数据自然可以跟着变 const dataOwners = new Map>(); for (const [anchorKey, serialized] of bindings) { for (const path of serialized.match(/page\.[a-zA-Z0-9_]+/g) ?? []) { const owners = dataOwners.get(path) ?? new Set(); owners.add(anchorKey); dataOwners.set(path, owners); } } return { anchors: collectAnchors(decl.html), bindings, initProps, events, dataOwners }; } /** 锚点键("父>子" 形态)取末段做范围匹配 */ function tailOf(key: string): string { const parts = key.split('>'); return parts[parts.length - 1]; } function inScope(key: string, scope: ReviseScope): boolean { const tail = tailOf(key); return ( scope.touched.includes(tail) || scope.added.includes(tail) || scope.removed.includes(tail) || scope.touched.includes(key) ); } /** * 漂移检测:返回 issues(空数组=没有越界改动)。 * 只在 revise 模式调用;基线与产物均已过静态闸门。 */ export function detectReviseDrift( base: PageDeclarations, next: PageDeclarations & { notes?: string }, ): string[] { const scope = parseReviseScope(next.notes); if (!scope) { return [ '[漂移对账] notes 缺少 revise_scope:请在 notes 首行输出 ' + 'revise_scope {"touched":["锚点id"],"added":[],"removed":[]},' + '声明本次改动涉及的锚点(程序据此核对未提及部分是否被误改)', ]; } const before = collectFeatures(base); const after = collectFeatures(next); const issues: string[] = []; // ① 锚点增删必须在范围内 for (const anchor of after.anchors) { if (!before.anchors.has(anchor) && !scope.added.includes(anchor)) { issues.push(`[漂移对账] 新增锚点 "${anchor}" 未在 revise_scope.added 中声明`); } } for (const anchor of before.anchors) { if (!after.anchors.has(anchor) && !scope.removed.includes(anchor)) { issues.push( `[漂移对账] 锚点 "${anchor}" 被删除但未在 revise_scope.removed 中声明——` + '未提及的部分必须逐字保留', ); } } // ② 绑定变更必须在范围内 for (const [key, value] of after.bindings) { const prev = before.bindings.get(key); if (prev === undefined) { if (!inScope(key, scope)) { issues.push(`[漂移对账] 新增绑定 "${key}" 未在 revise_scope 中声明`); } continue; } if (prev !== value && !inScope(key, scope)) { issues.push(`[漂移对账] 绑定 "${key}" 被修改但未在 revise_scope 中声明(原:${prev})`); } } for (const [key] of before.bindings) { if (!after.bindings.has(key) && !inScope(key, scope)) { issues.push(`[漂移对账] 绑定 "${key}" 被删除但未在 revise_scope 中声明`); } } // ③ 演示数据变更必须在范围内("悄改数值/文案"的主战场)。 // 归属规则:数据键归绑定它的锚点——申报改了该锚点,它的数据允许同步变化; // 没有任何归属锚点(孤立数据)时按键名自身判定。 const dataInScope = (key: string): boolean => { if (inScope(key, scope)) { return true; } const owners = before.dataOwners.get(key) ?? after.dataOwners.get(key); if (!owners || owners.size === 0) { return false; } return [...owners].some((anchorKey) => inScope(anchorKey, scope)); }; for (const [key, value] of after.initProps) { const prev = before.initProps.get(key); if (prev !== undefined && prev !== value && !dataInScope(key)) { issues.push( `[漂移对账] initProps "${key}" 的值被修改但未在 revise_scope 中声明——` + '未要求改的数据必须逐字保留', ); } } for (const [key] of before.initProps) { if (!after.initProps.has(key) && !dataInScope(key)) { issues.push(`[漂移对账] initProps "${key}" 被删除但未在 revise_scope 中声明`); } } // ④ 事件行增删必须在范围内(按行首锚点判定) const eventAnchor = (line: string): string => line.split('.')[0]?.trim() ?? line; for (const line of after.events) { if (!before.events.has(line) && !inScope(eventAnchor(line), scope)) { issues.push(`[漂移对账] 新增事件 "${line}" 未在 revise_scope 中声明`); } } for (const line of before.events) { if (!after.events.has(line) && !inScope(eventAnchor(line), scope)) { issues.push(`[漂移对账] 事件 "${line}" 被删除但未在 revise_scope 中声明`); } } return issues; } // ─── 蓝图漂移对账(revise 首选路径) ──────────────────────────────────────── // // 蓝图是 JSON,漂移检测退化为"区块级深比较":申报改了哪些区块,其余必须逐字相同。 // 比文本声明的对账简单一个量级,也更难被绕过。 import type { SomaPageBlueprint } from './BlueprintCompiler'; /** 区块身份:优先 component 名,栅格分组用 group:标题 */ function blockIdentity(block: Record, index: number): string { if (block.group === 'grid') { return `grid:${String(block.title ?? index)}`; } return String(block.component ?? `block${index}`); } /** * 新增/删除 grid 的范围申报兼容两种等价写法:直接写 grid 身份,或把组内全部 * component 逐一列出。提示词长期要求的是组件名;检测器只认 grid 内部身份会让 * 合法修订无论重试多少次都过不了。`every` 保证夹带一个未申报组件仍会被拦住。 */ function groupComponentsDeclared( block: Record, declared: readonly string[], ): boolean { if (block.group !== 'grid' || !Array.isArray(block.blocks) || block.blocks.length === 0) { return false; } return block.blocks.every((child) => { if (!child || typeof child !== 'object') return false; const component = (child as Record).component; return typeof component === 'string' && declared.includes(component); }); } /** * 蓝图漂移检测:范围外的区块必须与底稿逐字相同。 * 返回 issues(空 = 无越界改动)。 */ export function detectBlueprintDrift( base: SomaPageBlueprint, next: SomaPageBlueprint, notesCarrier: { notes?: string }, ): string[] { const scope = parseReviseScope(notesCarrier?.notes); if (!scope) { return [ '[漂移对账] notes 缺少 revise_scope:请在 notes 首行输出 ' + 'revise_scope {"touched":["被改区块的 component 名"],"added":[],"removed":[]}', ]; } const issues: string[] = []; const inScopeName = (name: string): boolean => scope.touched.includes(name) || scope.added.includes(name) || scope.removed.includes(name); const baseBlocks = new Map }>(); (base.blocks ?? []).forEach((block, index) => { const record = block as unknown as Record; baseBlocks.set(blockIdentity(record, index), { serialized: stableStringify(block), block: record }); }); const nextBlocks = new Map }>(); (next.blocks ?? []).forEach((block, index) => { const record = block as unknown as Record; nextBlocks.set(blockIdentity(record, index), { serialized: stableStringify(block), block: record }); }); for (const [identity, current] of nextBlocks) { const before = baseBlocks.get(identity); if (before === undefined) { if (!inScopeName(identity) && !groupComponentsDeclared(current.block, scope.added)) { issues.push(`[漂移对账] 新增区块 "${identity}" 未在 revise_scope.added 中声明`); } continue; } if (before.serialized !== current.serialized && !inScopeName(identity)) { issues.push(`[漂移对账] 区块 "${identity}" 被修改但未在 revise_scope 中声明——未要求改的区块必须逐字保留`); } } for (const [identity, previous] of baseBlocks) { if (!nextBlocks.has(identity) && !inScopeName(identity) && !groupComponentsDeclared(previous.block, scope.removed)) { issues.push(`[漂移对账] 区块 "${identity}" 被删除但未在 revise_scope.removed 中声明`); } } // 页面标题/副标题也算内容:未申报不得变 if (base.title !== next.title && !inScopeName('title')) { issues.push('[漂移对账] 页面标题被修改但未在 revise_scope 中声明(如确需修改请写 "title")'); } if ((base.subtitle ?? '') !== (next.subtitle ?? '') && !inScopeName('subtitle')) { issues.push('[漂移对账] 页面副标题被修改但未在 revise_scope 中声明(如确需修改请写 "subtitle")'); } // 存量蓝图没有 slug:首次补齐页面身份属于协议迁移,不算用户内容漂移。 // 一旦底稿已有稳定 URL,修改它就必须显式申报,避免修订悄悄换地址。 if (base.slug !== undefined && base.slug !== (next.slug ?? '') && !inScopeName('slug')) { issues.push('[漂移对账] 页面 slug 被修改但未在 revise_scope 中声明(如确需修改请写 "slug")'); } return issues; }