Live v2:架构方案¶
状态(2026-07-27):已实现。 P0 至 P3 一次性落地:根清单(
skill/scripts/live/roots.mjs,每个 live CLI 通过enterLiveRoot重新锚定根)、带每个变体渲染真相和持久错误卡的挂载确认协议、服务端优先水合、AST 脚手架生成器(live/svelte-ast.mjs,带源码预览回退)、统一机械式接受(live/accept-css.mjs,编译器剪枝;live-complete拒绝脏源码)、每次发布递增的修订版预览目录、带命名根不匹配诊断的探针、monorepo 运行时夹具,以及针对预览树 404 失败的 e2e 测试。与 3.3 节有一个刻意偏差:预览树保持在node_modules/.impeccable-live下,因为 SvelteKit 将 viteserver.fs.allow限制为 src/lib、routes、.svelte-kit 和 node_modules(已验证:应用根下的.impeccable/会 403);过时问题通过每次发布的修订目录解决,而非依赖 watcher。P4(注册表、整合、控制流夹具、夜间矩阵)正在进行中。
由 2026-07-25 的 Codex 会话驱动(位于 ~/code/agent-reviews,会话 019f9bf4-24a3-7661-afc5-bba7eb1e327f),其中一次 SvelteKit monorepo live 会话遇到一连串失败:错误的项目根、变体模块静默 404、{#each} 块被扁平化、模块重新发布过期、浏览器状态不可恢复地丢失,以及一次接受操作是追加 CSS 而非合并 CSS。下面的每一条发现都已对照当前源码验证;这不是 Codex 报告的转录。
1. 已验证的发现(Codex 声称 → 代码中的根本原因)¶
| # | Codex 发现 | 已验证的根本原因 |
|---|---|---|
| 1 | monorepo 根错误 | findMonorepoRoot()(skill/scripts/context.mjs:262-303)只能识别“官方”monorepo(workspaces 字段、turbo/pnpm/nx/lerna 标记)。一个内部嵌套 website/ 应用的普通仓库会落入非 monorepo 分支,并以 cwd 为根。nearestTargetContextRoot 故意忽略 package.json 作为标记。没有任何机制将 dev server 的根与 projectRoot 关联起来。 |
| 2 | 将发布误认为渲染 | arrivedVariants 是在 agent 的 --reply done 时从 expectedVariants 回填的(live/session-store.mjs:211-241)。在组件预览路径上,浏览器从不发送挂载检查点;成功调用只执行 saveSession()(live-browser.js:5592)。协议中任何地方都没有 mounted / mount_failed。 |
| 3 | 挂载失败静默 | 导入 catch 是 console.error + return false(live-browser.js:5413-5419)。首次挂载失败会调用 abortSvelteComponentInjection(:5621-5651),它会清除 localStorage、重置为 PICKING、显示 5 秒 toast,并且不告知服务器。变体切换失败完全没有用户反馈(:4941-4950)。 |
| 4 | Svelte {#each} 被扁平化 |
属性提取是纯正则(live/svelte-component.mjs:18,44-88)。{#each} / {/if} 块标记成为标量字符串属性(prop0、prop5);一个 5 项循环被脚手架生成成一个 <li>。还有一个独立的槽位偏移 bug:buildSvelteExpressionTextMap(live-browser.js:5830-5871)按索引将源码标记与实时文本节点配对,而块标记会消耗槽位。 |
| 5 | 临时模块路径脆弱 | 模块写入 <projectRoot>/node_modules/.impeccable-live/,但从 location.origin 根相对路径导入(live-browser.js:5163-5165)。两个根必须按约定一致。清单/CSS 来自 helper 的 /source;可执行模块来自 dev server。一个工件两条传输通道。 |
| 6 | 重新发布过期 | 缓存破坏只是客户端对叶子模块的 ?t=Date.now()(live-browser.js:5377)。Vite 的 watcher 忽略 node_modules,因此重写 vN.svelte 永远不会使 dev server 的转换缓存失效。脚手架是只写一次的(svelte-component.mjs:173)。路径中没有修订号;运行时 shim 被永久记忆化。 |
| 7 | 浏览器本地状态是单点故障 | 每条恢复路径都首先以 localStorage 为门(live-browser.js:8196-8246)。服务器 activeSessions 只是丰富一个已知的本地 id。挂载失败会故意清除本地状态,从而制造了孤立持久会话的情况。 |
| 8 | 父上下文未被发现 | 上下文根和项目根是同一个变量。在非 monorepo 分支中 repoRoot = absCwd(context.mjs:213),所以 website/ 永远不会向上查找一级以寻找 PRODUCT.md / DESIGN.md。Codex 会话通过仍然留在磁盘上的符号链接绕过了这一点。 |
| 9 | DESIGN 面板与 helper 不一致 | 两个原因:面板是内容驱动的(需要 frontmatter/sidecar,live-browser.js:11037-11068),而 hasDesign 是存在性驱动的;另外,服务器的上下文快照在模块加载时冻结(live-server.mjs:61-65),而 live.mjs 每次启动重新解析并复用正在运行的服务器。 |
| 10 | 停止后留下 __runtime.js |
清理器跳过非目录和 __* 名称(svelte-component.mjs:732-742)。在 agent-reviews 中确认仍在磁盘上。同样未清理的还有:接受回执、会话日志、os.tmpdir() 中的延迟接受。 |
| 11 | CSS 是追加而非合并 | appendCssToSvelteStyle(svelte-component.mjs:278-296)在最后一个 </style> 之前拼接变体 CSS。不存在任何协调机制。更糟的是:mergeOriginalTopLevelAttrs(:646-681)将原始根的类复制到新标记上,从而保证旧规则继续匹配。 |
| 12 | 参数烘焙不完整 | sanitizeAcceptedSvelteCss 在 CSS 包含 data-impeccable-variant 之前提前返回(svelte-component.mjs:311-312),而创作规则在此路径上禁止该属性,因此整个 steps/toggle 剪枝管线是不可达的死代码。只有 range 变量被替换;toggle 将原始 JS 布尔值(true)烘焙进 CSS。 |
| 13 | 格式被破坏 | line.trimStart() + 每个标记行的单一平级缩进(svelte-component.mjs:544-547);每行 CSS 重新缩进为 2 个空格(:280)。任何地方都没有格式化器。Branch-H(HTML/JSX)路径保留了相对缩进;Svelte 路径相对于其同级路径发生了回退。 |
| 14 | 预览级联 ≠ 已接受级联 | Svelte 的作用域是编译期、按文件生效的;分离的预览组件不继承路由的任何 scoped CSS,因此 variants 重新实现了所有样式。运行时随后第二次注入 variant CSS,重新加前缀且未加哈希(live-browser.js:5220-5280),其特异性与编译后的副本不同。 |
| 18 | Steer 操作提示 | 没有发送按钮;只能按 Enter(live-browser.js:9558-9712)。加载状态不错,但没有队列位置反馈,120 秒超时归咎于错误的组件。元素级 Go 栏有可见的提交按钮;steer 栏没有。 |
发现 15-17(hook 反模式缺口、文案质量、视觉碰撞检测)属于 design-hook 范围,而非 live 范围;另行跟踪。
解释这些为什么都会随版本发布的结构性事实:
- 826 行的
svelte-component.mjs负责脚手架、CSS 追加和参数固化,却没有任何直接单元测试。唯一的运行时 Svelte 夹具(vite8-sveltekit)没有 props、没有{#if}、没有{#each}、没有<script>。 - 没有运行时夹具的 repo root 与 app root 不同。
nextjs-turborepo仅静态,且其唯一的 wrap 用例断言element_not_found。 - 端到端控制台检查将 404 列入白名单,而这正是缺失 variant 模块的确切信号。
- Accept 有两种实现。正确的合并语义(carbonize 的五个步骤)只作为
live.md中的文字描述存在,且仅适用于 HTML/JSX 路径。Svelte 路径硬编码carbonize: false= “无事可做”。 projectRoot是一个环境参数(子进程的 cwd),在至少 12 个脚本中被独立重新推导,从未持久化,也从未验证。
2. 诊断:五个结构性问题¶
- 根身份被混为一谈且是环境隐式的。 四个不同概念作为一个 cwd 传播:repo root(git)、app root(开发服务器所服务的)、context root(PRODUCT.md / DESIGN.md 所在位置)、session root(
.impeccable/live状态存储位置)。没有任何机制根据现实校验这一选择。 - 协议对渲染没有真值。 服务器的状态机止步于 “agent 说完成”。编译、导入和挂载发生在网络边界的另一端,没有确认通道,因此发布之后的每次失败对 agent 和日志都不可见。
- Svelte 路径是一个平行宇宙。 它有自己的脚手架(正则而非 AST)、自己的服务路径(开发服务器 + helper,两种传输)、自己的 CSS 语义(脱离的编译作用域、双重注入)、以及自己的 accept(追加,无 carbonize)。本次会话最严重的每个 bug 都存在于那个宇宙中。
- 验收(Acceptance)强制了错误的东西。 机械代码很好地处理了锁定、回执和标记,但真正的质量契约(合并 CSS、剪除死分支、固化参数、格式化)对一条路径只是文字描述,对另一条路径则完全缺失。
live-complete.mjs不读取文件就进行确认。 - 测试断言的是记账,而非现实。 DOM 挂载只在 fake 模式下针对一个 variant 验证过一次;component-preview 的“到达”是浏览器自己的计数器;已接受源码的检查是标记缺失加上一个粗略的正则。本次会话造成严重问题的失败模式(404、过期模块、嵌套根)没有测试。
3. 目标架构¶
3.1 根 manifest:解析一次、显式传递、在 attach 时验证¶
在一个模块中引入单一的 resolveRoots(target),生成一个持久化 manifest:
{
"appRoot": "/repo/website", // nearest dir above target with package.json + dev-server config (vite/svelte/next/astro/nuxt config file)
"repoRoot": "/repo", // git root
"contextRoot": "/repo", // nearest dir from appRoot up to repoRoot holding PRODUCT.md/DESIGN.md
"sessionRoot": "/repo/website/.impeccable/live",
"resolvedFrom": "target:website/src/routes/+page.svelte"
}
- app-root 检测以是否存在开发服务器配置为关键,而不是 monorepo 品牌标记。无论 workspaces 字段如何,带有
vite.config.js的嵌套website/都优先于 repo root。“官方 monorepo”逻辑只是一个输入,不再是守门人。 - context 发现从 appRoot 向上走到 repoRoot(git 边界),逐层检查。这使 agent-reviews 的 symlink 变通方案不再必要,并消除
repoRoot = absCwdbug。 - manifest 在启动时写入
server.json。每个辅助脚本(live-wrap、live-accept、live-poll、live-resume、live-status、adapters)都接受--root或读取 manifest;没有任何脚本从 cwd 重新推导根。从不同 cwd 调用的辅助脚本通过向上搜索找到 manifest 并使用它,而不是静默派生第二个空项目(这也修复了readLiveServerInfo中的双服务器双端口失败)。 - Attach 时验证:在握手时,浏览器通过开发服务器源获取 helper 在假定的 app root 下写入的探测模块(
<appRoot>/.impeccable/live/preview/__probe.js)。如果它返回 404,则根假设有误,或开发服务器不服务该树;会话在启动时失败,并给出命名错误(preview_unreachable,同时包含假定的根和失败的 URL),而不是在第一个 variant 时静默失败。这一项检查本可以立刻捕获整个 agent-reviews 根目录惨败。
3.2 带挂载确认的交付状态机¶
让 variant 生命周期对每个 variant 显式化:published → fetched → compiled → mounted | failed。
- 浏览器在挂载成功后发出
variant_mounted {variant, revision},并在每个 import/mount catch(包括 variant 切换)中发出variant_mount_failed {variant, url, error}。这些是一等事件(如agent_error),不是检查点,因此它们会到达 agent 的轮询队列。 - 只有当至少一个 mount 确认到达时,服务器阶段才达到
variants_ready;新增variants_published阶段填补中间空白。arrivedVariants只能由 mount 确认递增;删除expectedVariants回填逻辑。 - UI:挂载失败会在栏中渲染持久错误卡片(失败的 URL、错误、Retry 按钮),并保持会话存活。删除
abortSvelteComponentInjection的“清空一切”行为;状态重置仅在用户明确丢弃/退出时发生。 live-resume.mjs/live-status.mjs报告每个 variant 的挂载状态,以便 agent 区分 “用户正在比较” 与 “从未渲染任何内容”。
3.3 变体服务:单一传输、修订号路径与监视目录¶
- 将预览树从
node_modules/.impeccable-live/移动到<appRoot>/.impeccable/live/preview/<sessionId>/r<revision>/。在应用根目录下,Vite(以及 SvelteKit/Astro/Nuxt/TanStack,均为 Vite 家族)会提供并监视该目录,因此重新发布时的失效处理是原生 HMR,而不是用?t=这层遮羞布。 - 路径中包含修订号,每次发布都会递增。重新发布 = 新目录;旧目录被删除。陈旧性在结构上变得不可能;不再有查询字符串的把戏,也不再面临记忆化运行时垫片问题(垫片就在带修订号的树内部)。
- 浏览器通过同一个开发服务器源导入模块并读取参数/清单;辅助的
/source路由仅用于源文件。单一传输,单一缓存机制。从注入脚本自身的 URL(而不是location.origin)推导出 URL,从而尊重开发服务器的base。 - 在停止和启动时清扫整个预览树(修复崩溃和
kill -9的遗留问题)。回执、日志和延迟接受(deferred accepts)移到同一个sessionRoot下,并在启动时进行保留期清扫;os.tmpdir()中的延迟接受文件被废弃。
3.4 Svelte 基于真实 AST 的脚手架¶
被编辑的项目安装了 svelte;脚手架工具可以从应用的 node_modules(从 appRoot 解析)中 import('svelte/compiler')。
parse()生成真实的模板 AST。控制流块({#each}、{#if}、{#await}、{#snippet})作为块被保留。集合以结构化值的形式穿过 prop 契约({#each}源表达式变成一个从实时 DOM 水合(hydrated)的数组 prop),绝不会变成扁平的标量文本。- 从实时 DOM 到 prop 的文本映射不再按索引进行配对;AST 会告诉我们哪些文本节点是表达式、哪些是静态的。
- 往返不变性由测试强制保证:对每个 fixture 组件,
restore(scaffold(source)) === source。这正是正则版本曾悄悄破坏的性质。 - 如果无法解析
svelte/compiler或解析失败,则回退到源码预览模式(其他框架都使用的包装路径),而不是交付错误的脚手架。降级但正确,胜过聪明但损坏。 - 用源组件
<style>中与所选元素匹配的规则为每个变体存根播种,使变体从真实的级联开始,而不是盲目重新实现;同时删除运行时的第二次重新加前缀的 CSS 注入(applySvelteComponentVariantStyle):编译后的组件已经携带其作用域样式。
3.5 统一接受流水线、机械式协调与强制后置条件¶
将 Branch S 和 Branch H 合并为一个流程:拼接标记 → 协调 CSS → 烘焙参数 → 格式化 → 验证。
- CSS 协调,而非追加。 同时解析组件现有的样式块和变体 CSS(将
css-tree或等价的小型解析器引入skill/scripts/live/)。替换选择器匹配的规则,追加真正的新规则。然后使用框架自己的编译器作为死规则判定器:编译已接受的.svelte文件,并删除编译器报告为未使用选择器的每条规则。这样就能确定性地移除本次会话产生的过期分割线规则。 - 参数烘焙由
params.json驱动,而非正则嗅探:range→ 替换为字面量(或设置变量的新默认值),steps→ 保留选中的[data-p-*]分支,重写为语义化选择器,删除兄弟分支;toggle→ 规范化为0/1,同样的分支逻辑。从提升后的标记中清除所有data-p-*/data-impeccable-*属性。全部是纯函数,全部经过单元测试。 - 格式化:在拼接时保留相对缩进(移植 Branch H 的
deindentContent方法),然后如果项目配置了格式化器,就运行项目自己的格式化器(检测 prettier/biome 配置;只对改动过的文件运行;如果不存在则静默跳过)。 - 后置条件门禁:一个
verifyAcceptedSource(file)扫描器(无标记、无data-p-*、无var(--p-、与接受前相比无重复选择器、文件能在其框架中解析)。live-complete.mjs会运行它,并在存在脏内容时拒绝完成,将发现的问题返回给 agent 修复。carbonize 的文本契约由此得到强制执行,同一个扫描器也会在每个 e2e fixture 中运行。 - 将拒绝生成文件的逻辑加入 Svelte 路径(目前仅存在于 Branch H)。
3.6 服务端优先的浏览器再水合¶
- 在 SSE
connected时,如果 localStorage 没有会话,但服务器报告该页面 URL 存在活动会话,则从服务器快照重新水合:会话 id、阶段、变体数量、挂载状态、预览清单、参数值。localStorage 成为仅浏览器端额外信息(滚动位置、选中锚点的视口提示)的缓存,而不是权威来源。 - 快照已经携带了几乎所有内容(
summarizeActiveSessionForClient);将选中元素锚点描述符添加到generate事件载荷中,使其被记入日志并可重放。 - 结果:关闭标签页、清空存储、不同的浏览器配置文件,或挂载失败后的清除,都能恢复到同一个对比状态。
live-resume.mjs那种“让用户重新点击 Go”的时代结束了。
3.7 框架支持:作为带一致性契约的注册表¶
目前,框架知识散落在 live-inject.mjs、两个适配器、live-wrap.mjs 和浏览器特殊逻辑中。定义一个注册表(skill/scripts/live/frameworks/<name>.mjs),其中每个条目声明:
detect(appRoot) → confidence
inject / remove → how live.js reaches the page (crash-safe: journal what was written, heal on boot)
previewStrategy → 'source-wrapper' | 'component' (+ scaffolder)
cssAuthoring → mode + styleTag
acceptStrategy → shared pipeline options
conformance → fixture name(s)
“受支持的框架”于是有了操作性定义:它的 fixture 通过共享的一致性场景测试组(选择 → Go → 所有变体完成挂载验证 → 调整参数 → 接受 → 后置条件扫描 → 重载后恢复 → 挂载失败恢复)。添加一个框架 = 添加一个注册表条目 + 一个 fixture;测试组对所有框架相同。正是这一点让每个框架都坚如磐石,而不只是零散经验上的可用。
4. 测试计划¶
单元测试(默认套件,低成本):
1. svelte-component 脚手架:往返属性测试(restore(scaffold(x)) === x),语料库涵盖 {#each}、{#if}/{:else}、{#await}、{@const}、snippet、<script module>、含表达式的属性。
2. 接收管道:CSS 协调器(replace/append/delete 用例、@media 嵌套)、按类型进行参数烘焙(包括默认值中的 calc() 和布尔归一化)、后置条件扫描器、制表符和空格缩进文件的缩进保留。
3. 根 manifest:针对普通嵌套应用、workspaces monorepo、turbo、app 根目录之上的上下文、target 与 cwd 不一致、双服务器预防的表驱动用例。
运行时 e2e(强化):
4. Monorepo fixture(最高优先级):repo 根目录有自己的 package.json,应用位于 apps/web 或 website/,PRODUCT.md/DESIGN.md 位于 repo 根目录,dev server 从应用目录启动。断言 manifest 根、探针验证、预览 URL,以及 accept 落在正确的树中。目前没有任何测试针对真实服务器验证 root ≠ app root。
5. 将 vite8-sveltekit-stateful 提升到运行时并添加 {#each};断言表达式存活(assertSourceContains: ["{#each", "{expenses[0].name}"])。将相同的表达式存活断言添加到 vite8-react-mapped-list({item.title}),复制 vite8-react-tsx-repeated-aside 模式;目前测试套件会放行将字面量烘焙进映射列表的行为。
6. 每个变体的挂载证明:在 fake agent 中按变体设置计算样式标记,对 v1/v2/v3 在两个预览路径上通过 DOM 断言(绝不使用 debugState)。
7. 404 即失败:从 console allowlist 中移除 Failed to load resource ... 404;显式断言对预览树的请求均未返回 404。
8. 故障注入场景作为一等 fixture 选项:在循环前删除 v2.svelte(断言持久错误卡片 + Retry + 会话存活)、重新发布修正后的模块(断言新修订版挂载)、在会话中途杀死 localStorage(断言服务器重新水合)、在 accept 前停止 dev server。
9. 端到端参数:harness 点击 Tune、更改 range 和 steps 值、accept,并断言烘焙输出(目前 Tune 从未被点击,因此烘焙从未被实际练习)。
10. 每个 fixture 中已接收源码的质量:运行共享的后置条件扫描器 + prettier --check(在 fixture 有配置的地方),而不是一个粗略的正则。
fake agent 真实性: 11. 变体必须包含嵌套标记(当前单一扁平元素输出在结构上无法捕获容器 bug)、列表 fixture 上的映射列表,以及每个变体不同的标记。
CI 节奏:
12. PR smoke 集合扩展为包含 monorepo fixture 和有状态 Svelte fixture,并运行故障场景(为它们去掉 SCENARIOS: core 门控)。完整的 20-fixture 矩阵从手动 workflow_dispatch 改为 nightly cron,失败时自动提交 issue。添加一个构建检查,确保 ci.yml 中的 fixture 名称与发现的运行时 fixture 匹配,使矩阵无法静默漂移。
5. 复杂度和开销削减¶
- 拆分
live-browser.js(11.7k 行,单文件)为构建时打包的 ES 模块(构建系统和browser-script-parts.mjs已存在)。在 jsdom 中对状态机和重新水合逻辑进行单元测试;目前浏览器运行时只能端到端测试。 - 单一协议模块:事件类型、阶段、检查点原因和 agent_phase 值作为共享枚举,由验证器、浏览器构建和文档导入。删除约 9 个没有任何代码发出的 agent_phase 值、重复的循环计数器,以及三个修订计数器中的两个。
- 单一根解析器、单一 glob 匹配器(目前有三个 glob 实现,带有"keep in sync"注释)。
- 会话存储:以 journal mtime 为键在内存中缓存快照;不再在每次 append/read 时重放完整 journal,也不再在读操作中作为副作用写入快照(
live-status目前会改变状态)。 - 崩溃安全适配器:注入会记录其写入内容;boot 修复遗留物(修复 SIGKILL 孤儿进程和从错误根目录
stop导致的孤儿进程)。stop清扫整个预览树、回执和已完成的会话 journal。 - Steer 栏:添加可见的 Send 按钮(Go 栏已有)、当 generate 持有租约时的队列位置反馈,并改写超时消息。
- DESIGN 面板:延迟提供上下文(在请求时解析,而不是在服务器模块加载时),使比
impeccable document存活更久的服务器不再撒谎;当 DESIGN.md 存在但没有可解析系统时,说明这一点("DESIGN.md found, no structured tokens")而不是"没有设计系统数据可用"。
6. 阶段划分¶
| 阶段 | 范围 | 规模 |
|---|---|---|
| P0: 止血 | 在 stop/boot 时清扫 __runtime.js + 预览树;持久挂载错误卡片 + 一等 variant_mount_failed 事件;删除挂载失败时的 localStorage 清除;上下文向上查找到 git 根;e2e:404 即失败 + 表达式存活断言(发现 3、8、10,部分 2) |
数天 |
| P1: 根 | 根 manifest、处处显式 --root、附加时探针验证、monorepo 运行时 fixture(发现 1、5、8) |
约 1 周 |
| P2: 交付真相 | 完整挂载确认状态机、arrivedVariants 仅来自 ack、服务器优先重新水合、恢复时呈现挂载状态、故障注入 e2e 场景(发现 2、3、7) |
约 1 周 |
| P3: Svelte + 接收 | 带源码预览回退的 AST 脚手架、appRoot 下的修订版预览树、单一传输服务、统一接收管道(协调 + 烘焙 + 格式化 + 后置条件门控)、有状态 Svelte fixture + 参数 e2e(发现 4、6、11-14) | 2-3 周 |
| P4: 整合 | 框架注册表 + 一致性测试组、浏览器运行时模块拆分、协议枚举修剪、会话存储缓存、nightly 完整矩阵(结构性) | 持续 |
P0-P2 独立于 P3,直接移除 Codex 评为成本最高的四类失败(根检测、挂载确认、Svelte 脚手架、接收合并);Svelte 脚手架和接收在 P3 落地,因为它们需要解析器和协调器基础。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 im/impeccable