From a5d6863d9ea871c5c0625dd999d99dad13fc0d9f Mon Sep 17 00:00:00 2001 From: yanghl Date: Thu, 18 Jun 2026 16:49:31 +0800 Subject: [PATCH] coding.mjs: 新增静态原型渲染门 Preview——截图基线 + 原型交互冒烟硬门 --- README.md | 5 +++++ workflows/coding.mjs | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5dbda02..30fdd3d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,11 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 │ └─ B-前端(后端全部打里程碑后,整体 1 个里程碑 tag) runBranchSetup(frontend-phase) + → 静态原型渲染门 preview(前端段开头:Playwright headless 逐页渲染 prototype/**/*.html 本身 + → 截图归档为可视化基线 + 跑原型自带交互点击冒烟 + 捕获 JS/console 错误。与行为门正交—— + 前者测静态原型、后者测实现。硬门:原型渲染失败 / 脚本未捕获异常(jsErrors)→仲裁(只许 retry/halt), + halt 即停下等人工回 plan/add-req 修原型后重跑(coding 不改原型源码);console.error 仅 advisory; + 唯环境未就绪(Playwright/浏览器缺失)才 retry→仲裁降级。无 prototype/ 则跳过) → 前端骨架占位阶段(router 全量 lazy 路由表 + FeStub 占位,保证中途任意时刻可构建可起; 含 e2e 基线脚手架:Playwright globalSetup 按注入时序注种子 + admin 登录 storageState; 含单测基线:vitest include 限定 tests/**/*.test.*——单测一律 frontend/tests/ 镜像 src/, diff --git a/workflows/coding.mjs b/workflows/coding.mjs index 80d2ff4..6e593b8 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -8,12 +8,16 @@ export const meta = { name: 'erp-coding', description: 'Run the entire ERP coding phase autonomously and silently: per-module backend+frontend feature loops, test gate, milestone tag.', phases: [ - { title: 'Router' }, { title: 'Schedule' }, { title: 'Backend' }, { title: 'Frontend' }, + { title: 'Router' }, { title: 'Schedule' }, { title: 'Backend' }, { title: 'Preview' }, { title: 'Frontend' }, { title: 'Behavior' }, { title: 'Gate' }, { title: 'Seed' }, { title: 'Milestone' }, ], // 注:'Behavior' = 阶段级行为验收门(v3)——整个前端阶段在 featureLoop 全部 FE 完成后只跑**一次** // 行为验收(含 fix 循环),不再在每个 FE 的 review 循环内做 approve 子门(v2 per-FE 形态已撤销)。 // 时序:featureLoop(frontend) → Behavior(行为门+fix)→ Gate(testGate 全量回归,兜底行为 fix 引入的回归)。 + // 注:'Preview' = 静态原型渲染门——前端段开头一次性渲染 prototype/**/*.html 本身(Playwright headless + // 截图归档 + 原型自带交互点击冒烟)。与 Behavior 正交:Preview 测**静态原型**、Behavior 测**实现**。 + // 硬门:原型渲染失败 / 脚本未捕获异常(jsErrors) → halt(原型属上游 plan 权威,coding 不改原型源码, + // 应回 plan/add-req 修后重跑);console.error advisory;环境未就绪 retry→仲裁。时序:Preview → Frontend(骨架+featureLoop)。 } const ROUTER_SCHEMA = { type:'object', additionalProperties:false, @@ -154,6 +158,38 @@ const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false, confidence:{type:'string', enum:['high','medium','low']} } } }, artifactPath:{type:'string'} } } +// PROTOTYPE_PREVIEW_SCHEMA:静态原型渲染门(phase('Preview'))返回。与 BEHAVIOR_GATE 正交—— +// 后者测「实现」(冷起全栈 + Playwright 驱动真前端),本门只渲染 *静态* prototype/**/*.html 本身: +// 逐页 headless 渲染→截图归档(可视化基线)→跑原型自带交互点击冒烟→捕获 JS/console 错误。 +// status:ok=渲染门正常跑完(含 advisory 问题);blocked=仅限环境未就绪(Playwright/浏览器缺失/超时)需仲裁。 +// 原型属上游 plan 权威、coding 绝不改原型源码——故渲染/JS 问题是 advisory(记 decisions+证据,绝不硬 halt)。 +const PROTOTYPE_PREVIEW_SCHEMA = { type:'object', additionalProperties:false, + required:['status','pagesFound','pagesRendered'], properties:{ + status:{type:'string', enum:['ok','blocked']}, + pagesFound:{type:'integer'}, // Glob 到的 prototype/**/*.html 总数 + pagesRendered:{type:'integer'}, // 成功渲染并截图的页数 + pages:{ type:'array', items:{ type:'object', additionalProperties:false, + required:['file','rendered'], + properties:{ + file:{type:'string'}, // prototype 相对路径 + rendered:{type:'boolean'}, + screenshot:{type:'string'}, // assets 相对路径(已归档,纳入版本管理) + controlsExercised:{type:'integer'}, // 实际驱动(点击/切换)过的控件数 + jsErrors:{type:'array', items:{type:'string'}}, // pageerror 文本(原型自身脚本质量信号) + consoleErrors:{type:'array', items:{type:'string'}}, // console.error 文本 + notes:{type:'string'} } } }, + // envError:仅环境未就绪(与「原型自身有问题」严格区分——后者是 advisory,不进 envError)。 + envError:{ type:'object', additionalProperties:false, + required:['kind'], properties:{ + kind:{type:'string', enum:['playwright-missing','browser-missing','timeout','none']}, + detail:{type:'string'} } }, + decisions:{ type:'array', items:{ type:'object', additionalProperties:false, + required:['question','choice','rationale'], + properties:{ + question:{type:'string'}, choice:{type:'string'}, rationale:{type:'string'}, + confidence:{type:'string', enum:['high','medium','low']} } } }, + artifactPath:{type:'string'} } } + // ── 微步骤 schemas(runBranchSetup / runMilestone / runCrossModule 用)───────── const WT_SCHEMA = { type:'object', additionalProperties:false, required:['clean'], properties:{ @@ -838,6 +874,72 @@ function seedGenPrompt(module, c) { ].filter(Boolean).join('\n') } +// ---- 静态原型渲染门(Preview)prompt ---- +// 与下方「前端行为验收」正交:行为门测**实现**(冷起全栈 + Playwright 驱动真前端),本门只渲染 +// **静态** prototype/**/*.html 本身——逐页 headless 渲染→截图归档(可视化基线)→跑原型自带交互点击 +// 冒烟→捕获 JS/console 错误。原型是 plan/add-req 的上游权威产物:本门**只渲染不修改**,原型自身问题 +// 走 advisory(记证据,不硬 halt)。不起后端/前端 app、不跑 setup-test-db/seed。 + +// prototypePreviewContract:渲染门硬约束(只读、不起全栈、唯一可写区 = .tmp + assets + 证据报告)。 +function prototypePreviewContract(c) { + return [ + '## 硬约束(非交互原型渲染子代理)', + '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹 AskUserQuestion / 等人输入。**绝不要尝试问人**。', + '- 你是**静态原型只读渲染门**:用 Playwright headless 渲染 `prototype/**/*.html` 本身、截图、跑原型自带交互——**不是**实现功能、**不是**起后端/前端 app、**不是**改任何源码。', + `- **作用域(唯一可写区)**:\`${c.root}/.tmp/prototype-preview/\`(一次性 runner,跑完即弃)+ 截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/prototype/\` + 证据报告 \`${c.root}/docs/superpowers/module-reports/prototype-preview.md\`。`, + '- **越界硬停**:**绝不**编辑 `prototype/` / `frontend/` / `backend/` / `sql/` / `scripts/` 下任何文件——原型是上游 plan 的权威产物,本门只渲染不修改。命中越界即以 `status:blocked` 或写清阻塞点结束。', + '- **不起全栈**:本门只渲染静态 HTML(`file://` 或仅服务 `prototype/` 的临时静态服务器),**绝不**冷起后端 / 前端 dev server、**绝不**跑 setup-test-db / seed。', + '- 证据报告**使用中文**;文件名 / 选择器标识符可用英文。', + '- **运行时确定性**:视口尺寸 / 临时目录名 / 截图文件名 / 临时端口一律确定性派生(按页面相对路径 / attempt 序号),**绝不**依赖时间戳 / 随机数。', + ].join('\n') +} + +// prototypePreviewPrompt:静态原型渲染门子代理完整流水线(step0-3 + schema)。 +// feItems:本前端阶段全部 FE-NN(仅作分组参考,不强制与原型页一一对应);attempt:环境 race 重试序号(1..)。 +function prototypePreviewPrompt(feItems, attempt, c) { + const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无 FE 清单)' + const tmpDir = `${c.root}/.tmp/prototype-preview` + const assets = 'docs/superpowers/module-reports/assets/prototype' + const evidence = 'docs/superpowers/module-reports/prototype-preview.md' + return [ + `# preview — 静态原型渲染门(headless,attempt=${attempt})`, + '', + prototypePreviewContract(c), + '', + '## 目标', + '渲染**全部静态原型** `prototype/**/*.html` 本身,产出两类产物:① 每页**截图**(可视化基线,供人工 / 行为门比对);② 原型**自带交互**的点击冒烟(按钮 / 链接 / 表单切换是否触发 JS 错误)。这是对静态设计原型的「查看界面 + 模拟操作」,与实现无关。', + `- 关联前端功能(仅作分组参考,不强制一一对应):${feList}。`, + attempt > 1 ? `- 本次 = attempt ${attempt}(上次 \`status:blocked\` 环境未就绪后重试)。` : '', + '', + '## step0 发现原型 + 探测环境', + `- Glob \`${c.root}/prototype/**/*.html\`,得页面清单 = \`pagesFound\`。**空**(项目无原型)→ 直接返回 \`{ "status":"ok", "pagesFound":0, "pagesRendered":0, "pages":[] }\`,不报错、不写任何文件。`, + '- 探测 Playwright 是否可用(`frontend/node_modules` 内或全局)+ 浏览器内核是否已装。不可用 → `status:"blocked"` + `envError.kind="playwright-missing"|"browser-missing"`(**不要**尝试 `npm install` / `playwright install`——环境就绪由上层处理)。', + '', + '## step1 渲染 + 截图(确定性)', + `- 入口清目录:删除并重建 \`${tmpDir}/\`(确定性、跨平台:\`fs.rmSync(p,{recursive:true,force:true})\` 后 \`fs.mkdirSync(p,{recursive:true})\`;**仅限**此路径)。`, + `- 在 \`${tmpDir}/\` 写一次性 runner(如 \`run.mjs\`):用 Playwright headless 逐页加载。优先 \`file://\` 直开;若原型用了相对 fetch / 根绝对路径资源导致 file:// 加载不全 → 起一个**仅服务 \`${c.root}/prototype/\` 静态目录**的临时本地服务器(端口确定性派生,\`finally\` 中 teardown)。`, + '- 视口确定性(默认 1440×900;某原型在 `` / 注释里声明了特定尺寸则用之并记 `decisions[]`)。每页 `waitForLoadState` 稳定后整页截图。', + `- 截图归档到**已纳入版本管理**的 \`${assets}/<安全文件名>.png\`(文件名按原型相对路径确定性派生,如 \`prototype/order/list.html\` → \`order__list.png\`;**不要**引用 \`.tmp\` 防断链)。\`pagesRendered\` = 成功截图页数。`, + '', + '## step2 原型自带交互冒烟(模拟操作)', + '- 每页注册 `page.on("pageerror")`(收 JS 异常)与 `page.on("console")`(收 `error` 级);注册 `page.on("dialog")` 自动 dismiss,防原生 `confirm/alert` 阻塞。', + '- 枚举页面可见可点控件(`button` / `a[href]` / `[role=button]` / `input` / `select` / 带 `onclick` 的元素),逐个**点击 / 切换一次**。`target=_blank` / 会 `window.open` 的只记不点(防新窗暴增)。`controlsExercised` 计实际驱动的控件数。', + '- 收集每页 `jsErrors`(pageerror 文本)/ `consoleErrors`(console.error 文本)——**原型自身**的脚本质量信号,仅作记录(advisory)。', + '- `finally` 中 kill 临时静态服务器(若起过)。', + '', + '## step3 证据落盘 + commit', + `- 写 \`${evidence}\`:按页一小节(原型相对路径 / 截图链接 / controlsExercised / jsErrors / consoleErrors / 视口决策),头部写 pagesFound-Rendered 汇总。渲染失败 / JS 错用**红字**标注为**阻断项**(上层据此 halt——回 /erp-workflow:plan-start 或 /add-req 修原型后重跑,**不在 coding 改**);console.error 标注为 advisory。`, + commitBlock(`${evidence} ${assets}`, 'docs(preview:prototype): 静态原型渲染基线 + 交互冒烟证据', undefined, c), + '', + '## 输出(必须符合下发的 PROTOTYPE_PREVIEW JSON schema)', + '- `status`: `ok`(渲染门正常跑完——即使某页渲染失败/有 JS 错也填 ok,硬/软由上层 JS 据 `pages[]` 分流)| `blocked`(**仅限**环境未就绪:Playwright / 浏览器缺失 / 超时)。', + '- `pages[]` **据实填**(上层据此判硬门):`rendered=false` 或 `jsErrors` 非空 = 硬缺陷(上层会 halt 等人工修原型);`consoleErrors` = 软信号(advisory)。`pagesFound` / `pagesRendered` 整数据实填;每页 file / rendered / screenshot / controlsExercised / jsErrors / consoleErrors / notes。', + '- `envError`: 无环境问题填 `{ "kind":"none" }`;环境未就绪填对应 kind + detail。', + '- 做过自主默认(视口 / 服务方式等)→ `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。', + '- schema 是 `additionalProperties:false`,不要返回额外字段。**不要自动重试**——重试由上层 Workflow 控制。', + ].filter(Boolean).join('\n') +} + // ---- 前端行为验收(阶段级行为门,v3)---- // 设计权威:docs/design/2026-06-05-frontend-behavior-stage-gate.md。 // 时机:featureLoop(frontend) 全部 FE 通过静态 review(req-done tag 已打)之后、testGate 之前, @@ -1099,6 +1201,8 @@ const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限 // - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。 const BEHAVIOR_STAGE_MAX = 3 const BEHAVIOR_ATTEMPT_MAX = 2 +// 静态原型渲染门(Preview)的环境 race 重起上限(Playwright/浏览器缺失/超时时自动重起,沿用 behavior attempt 思路)。 +const PROTOTYPE_PREVIEW_ATTEMPT_MAX = 2 const NULL_AGENT_PROBLEM = 'agent() 返回 null(子代理终端 API 错误 / 被用户跳过)——典型为断网或机器休眠;可重试' const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : '' @@ -2747,6 +2851,85 @@ const isBuildFailed = (r) => !!(r.envError && r.envError.kind === 'build-failed' // runBehaviorGateOnce:跑一次阶段级行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。 // 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 runBehaviorGate 推进)。 // behaviorRound:阶段门内的行为 fix 轮;内部 attempt 1..BEHAVIOR_ATTEMPT_MAX(环境 race 重起)+ 仲裁兜底。 +// runPrototypePreview:静态原型渲染门主驱动(被前端段开头调用,phase('Preview') 下)。 +// 与 runBehaviorGate 正交——本门只渲染静态 prototype/。两类边界严格分流: +// - **硬门(halt)**:原型**渲染失败**(rendered=false)或**原型脚本未捕获异常**(jsErrors=pageerror)—— +// 经仲裁 allowContinue:false(只许 retry/halt)。原型属上游 plan 权威、coding 不改原型源码——故 halt = +// 「停下等人工回 /erp-workflow:plan-start 或 /add-req 修原型后重跑」,不是在 coding 里改原型。 +// - **advisory**:console.error(弱信号,缺图标/CDN 警告等噪声多)——只记 decisions 不阻断。 +// - **环境未就绪**(Playwright/浏览器缺失/超时):retry→仲裁 allowContinue:true(不让「机器没装浏览器」卡死 coding)。 +// 终波恒宽 1、主根执行(c.lane==null);静态渲染无 DB、无需 ERP_TEST_DB_SCHEMA 注入。 +async function runPrototypePreview(feItems, c) { + const lbl = (a) => `preview:proto:a${a}` + let attempt = 1 + let pv = await agentR(prototypePreviewPrompt(feItems, attempt, c), + {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA}) + recordDecisions('preview:prototype', pv.decisions, c.decisions) + // 环境未就绪 → 自动重起 attempt 1→PROTOTYPE_PREVIEW_ATTEMPT_MAX;仍 blocked → 进仲裁循环。 + while (pv.status === 'blocked' && attempt < PROTOTYPE_PREVIEW_ATTEMPT_MAX) { + attempt += 1 + pv = await agentR(prototypePreviewPrompt(feItems, attempt, c), + {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA}) + recordDecisions('preview:prototype', pv.decisions, c.decisions) + } + for (let adj = 1; pv.status === 'blocked' && adj <= ADJUDICATE_MAX; adj++) { + const ev = pv.envError || { kind: '?', detail: '' } + // allowContinue:true——渲染门只产可视化基线(非硬正确性边界);环境长期缺 Playwright 时仲裁可降级跳过, + // 不让「机器没装浏览器」卡死整个 coding(与行为门的 allowContinue:false 严格区分)。 + const verdict = await adjudicate('preview-env:prototype', + { problem: `原型渲染门环境未就绪 envError=${ev.kind}: ${ev.detail || ''}`, envError: pv.envError || null, allowContinue: true }, + 'Preview', adj, c.root) + if (verdict.action === 'continue') { + log(`原型渲染门:环境未就绪经仲裁降级为口头建议继续(${ev.kind})——本次跳过原型可视化基线`) + recordDecisions('preview:prototype', + [{ question: '原型渲染门环境未就绪', choice: '降级继续(跳过可视化基线)', rationale: ev.detail || ev.kind, confidence: 'low' }], c.decisions) + return pv + } + if (verdict.action !== 'retry') throw new Error(`HALT preview-env prototype: ${verdict.rationale || ev.kind}`) + attempt += 1 + pv = await agentR(prototypePreviewPrompt(feItems, attempt, c), + {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA}) + recordDecisions('preview:prototype', pv.decisions, c.decisions) + } + if (pv.status === 'blocked') throw new Error(`HALT preview-env prototype: ${ADJUDICATE_MAX} 轮仲裁后原型渲染门仍环境未就绪`) + // 至此 env 就绪(status:ok)。硬门评估(含仲裁 retry 重跑——疑似一次性渲染抖动/超时被误判时重跑一次门): + // 硬缺陷 = 渲染失败(rendered=false) 或 原型脚本未捕获异常(jsErrors);console.error 仅 advisory(弱信号)。 + for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) { + if (pv.pagesFound === 0) { + log('原型渲染门:项目无 prototype/**/*.html,跳过(无可视化基线可产)') + return pv + } + const pages = Array.isArray(pv.pages) ? pv.pages : [] + const hardBroken = pages.filter(p => p && (!p.rendered || (p.jsErrors || []).length)) + const consoleOnly = pages.filter(p => p && p.rendered && !(p.jsErrors || []).length && (p.consoleErrors || []).length) + // advisory:渲染成功但有 console.error 的页只记 decisions,不阻断。 + if (consoleOnly.length) { + recordDecisions('preview:prototype', consoleOnly.map(p => ({ + question: `原型 ${p.file} 有 console.error`, + choice: '记录不阻断(弱信号,回 plan/add-req 酌情修)', + rationale: `consoleErrors=${(p.consoleErrors || []).length}`, + confidence: 'low' })), c.decisions) + } + if (!hardBroken.length) { + log(`原型渲染门:${pv.pagesRendered}/${pv.pagesFound} 页全部渲染通过(无渲染失败/JS 异常)——可视化基线已归档${consoleOnly.length ? `;${consoleOnly.length} 页有 console.error(advisory)` : ''}`) + return pv + } + const digest = hardBroken.map(p => `${p.file}(rendered=${p.rendered}, jsErrors=${(p.jsErrors || []).length})`).join('; ') + // allowContinue:false——原型渲染失败/脚本异常是硬缺陷,仲裁只许 retry/halt,绝不放行带病原型进前端实现。 + const verdict = await adjudicate('preview-broken:prototype', + { problem: `静态原型渲染门硬缺陷(渲染失败 / 原型脚本未捕获异常):${digest}。原型属上游 plan 权威,coding 不改原型源码——应回 /erp-workflow:plan-start 或 /add-req 修原型后重跑。`, + allowContinue: false }, 'Preview', adj, c.root) + if (verdict.action !== 'retry') throw new Error(`HALT preview-broken prototype: ${verdict.rationale || digest}`) + // retry:重跑渲染门一次(仲裁判定疑似一次性,如渲染超时被误标 jsError)。 + attempt += 1 + pv = await agentR(prototypePreviewPrompt(feItems, attempt, c), + {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA}) + recordDecisions('preview:prototype', pv.decisions, c.decisions) + if (pv.status === 'blocked') throw new Error(`HALT preview-env prototype: 硬门 retry 后又遇环境未就绪 envError=${(pv.envError || {}).kind || '?'}`) + } + throw new Error(`HALT preview-broken prototype: ${ADJUDICATE_MAX} 轮仲裁后原型仍有硬缺陷(渲染失败/JS 异常)`) +} + async function runBehaviorGateOnce(feItems, behaviorRound, c) { const lbl = (a) => `behavior:frontend-phase:r${behaviorRound}:a${a}` let attempt = 1 @@ -3180,6 +3363,11 @@ async function runModule(module, c) { await runCrossModule(module, c) // 替代被删 hook,JS 编排:diff → 分类 → 写日志 } if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块) + // 静态原型渲染门(Preview):前端段开头先渲染 prototype/**/*.html 本身——Playwright headless + // 截图归档为可视化基线 + 跑原型自带交互点击冒烟。与行为门(测实现)正交;advisory(原型属上游 + // plan 权威,coding 不改原型源码,渲染/JS 问题只记证据不阻断)。静态渲染、不起全栈。终波主根执行。 + if (c.lane == null) phase('Preview') + await runPrototypePreview(module.feItems, c) if (c.lane == null) phase('Frontend') // 前端骨架占位 stage(设计 § 2,前置依赖 A):featureLoop 之前一次性建 App 外壳 + router 全量 lazy // 路由表(FeStub 占位)+ 无悬空导航——保证逐 FE 实现中途任意时刻 app 可构建可起、每 FE 路由可达, -- libgit2 0.22.2