#!/usr/bin/env node /** * verify-my-skills.mjs —— 「我的技能」分组(`poc/business-plugins/lib/client.js`,档案 100) * 的**结构性防线**:版式不换行 / 危险操作必须二次确认 / 锁定行不得渲染可点动作。 * * 为什么需要(三个都真踩过或极易再犯): * ① **换行是版式问题、不是逻辑问题**:v0.3.14 用户截图报「名称换行 + 按钮换行」,根因是自动列宽。 * 本分组沿用「卡片行 + 动作区 `margin-left:auto;white-space:nowrap`」,只要这三个声明任一被删, * 缺陷就会静默复发 —— 构建与语法检查都不报错。 * ② **不可逆操作漏确认**:v0.3.15 同类 —— 「删除」紧挨「停用」且无确认,一次误点就永久删掉数据。 * 本分组的「替换」与「删除」都必须先弹页内确认;`removeSkill()` 只允许被弹窗的 `onOk` 调用。 * ③ **锁定行多出可点按钮**:平台共享技能后端会 409,前端若仍渲染「停用/删除」, * 用户点下去只会拿到报错 —— 断言这些按钮只在 `s.locked` 为假的分支里。 * * 用法:node scripts/verify-my-skills.mjs 退出码 0=全绿 / 1=有失败 */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const read = (p) => readFileSync(join(ROOT, p), 'utf8') let failed = 0 const ok = (name, cond, extra = '') => { console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : '')) if (!cond) failed++ } const src = read('poc/business-plugins/lib/client.js') // ── ① 分组确实在(按内容不按注释:判据是"渲染代码里有这几样")── ok('分组头标题走 msk.group', /t\("msk\.group"\)/.test(src)) ok('上传按钮存在(msk.upload)', /t\("msk\.upload"\)/.test(src)) ok('技能行容器类 bp-skillRow 被渲染', /className: "bp-skillRow"/.test(src)) ok('技能列表容器类 bp-skillList 被渲染', /className: "bp-skillList"/.test(src)) ok('拖拽投放区 bp-drop 被渲染', /className: "bp-drop"/.test(src)) // ── ② 三个后端端点 + 两阶段替换链路齐全(少一个就是功能缺一半)── ok('GET/POST /api/skills/mine', /"\/api\/skills\/mine"/.test(src)) ok('POST /api/skills/mine/apply(第二阶段)', /"\/api\/skills\/mine\/apply"/.test(src)) ok('enable / disable 端点', /"\/enable"/.test(src) && /"\/disable"/.test(src)) ok('DELETE /api/skills/mine/:name', /method: "DELETE"/.test(src)) ok('处理 conflict 响应(不静默忽略同名)', /res\.d\.conflict/.test(src)) ok('提交 stagedId(替换确实走两阶段)', /stagedId: target\.stagedId/.test(src)) // ── ③ 危险操作必须二次确认 ── // `removeSkill` 函数体的**唯一**调用点应当是 skillDialog 的 onOk。 { const calls = [...src.matchAll(/onOk:\s*removeSkill/g)].length ok('removeSkill 只被弹窗 onOk 引用一次', calls === 1, `-> ${calls} 处`) ok('删除弹窗存在(if (delSkill) 渲染块)', /if \(delSkill\) \{/.test(src)) ok('删除弹窗讲清后果(msk.delBody)', /t\("msk\.delBody"\)/.test(src)) ok('替换弹窗存在(if (replaceTarget) 渲染块)', /if \(replaceTarget\) \{/.test(src)) ok('替换弹窗讲清"不可恢复"(msk.replaceWarn)', /t\("msk\.replaceWarn"\)/.test(src)) ok('替换弹窗给出旧/新文件数对照(msk.replaceFacts)', /t\("msk\.replaceFacts"\)/.test(src)) } // ── ④ 锁定行(平台共享技能)不得渲染动作按钮 ── // 代码形态:`s.locked ? null : jsxRuntime.jsxs("span", { key: "a", className: "bp-skillActions" …` { const gated = /s\.locked\s*\?\s*null\s*:\s*jsxRuntime\.jsxs\("span",\s*\{\s*\n?\s*key: "a",\s*\n?\s*className: "bp-skillActions"/.test(src) ok('动作区被 s.locked 门控(锁定行不渲染按钮)', gated) ok('锁定标记 + 说明(msk.locked / msk.lockedTip)', /t\("msk\.locked"\)/.test(src) && /t\("msk\.lockedTip"\)/.test(src)) } // ── ⑤ 版式防线:不换行三件套(少任何一个,v0.3.14 的换行就会复发)── const cssHas = (selector, decl) => new RegExp('\\.' + selector + '\\{[^}]*' + decl + '[^}]*\\}').test(src) ok('.bp-skillMeta 有 white-space:nowrap', cssHas('bp-skillMeta', 'white-space:nowrap')) ok('.bp-skillMeta 有 margin-left:auto(动作与事实右对齐)', cssHas('bp-skillMeta', 'margin-left:auto')) ok('.bp-skillActions 有 white-space:nowrap', cssHas('bp-skillActions', 'white-space:nowrap')) ok('.bp-skillName 有 white-space:nowrap', cssHas('bp-skillName', 'white-space:nowrap')) ok('.bp-skillName 有 text-overflow:ellipsis', cssHas('bp-skillName', 'text-overflow:ellipsis')) ok('.bp-skillName 有 overflow:hidden', cssHas('bp-skillName', 'overflow:hidden')) ok('.bp-skillName 有 min-width:0(否则 flex 子项不会收缩)', cssHas('bp-skillName', 'min-width:0')) ok('.bp-groupAct 有 margin-left:auto(上传按钮靠右)', cssHas('bp-groupAct', 'margin-left:auto')) ok('.bp-skillFact 用 tabular-nums(数值不跳,规范 §5)', cssHas('bp-skillFact', 'tabular-nums')) ok('.bp-skillRow 的 hover 反馈在(规范 §4.3)', /\.bp-skillRow:hover\{/.test(src)) ok('prefers-reduced-motion 覆盖了 .bp-skillRow', /prefers-reduced-motion[^}]*\{[^}]*\.bp-skillRow/.test(src)) // ── ⑥ 措辞防线:机制差异必须写在界面上(技能即时生效 vs 插件需重启)── { const zh = /"msk\.hint":\s*"([^"]+)"/.exec(src) ok('zh 的 msk.hint 存在', zh !== null) ok('zh 的 msk.hint 写明"无需重启"', zh !== null && zh[1].includes('无需重启'), zh ? '-> ' + zh[1] : '') const en = /"msk\.hint":\s*"([^"]+)"/g const all = [...src.matchAll(en)].map((m) => m[1]) ok('msk.hint 在 zh/en 各有一条(键数一致由 verify-models-dict 兜底)', all.length === 2, `-> ${all.length} 条`) ok('en 的 msk.hint 写明 no instance restart', all.length === 2 && /no instance restart/i.test(all[1])) } // ── ⑧ 分区改名 + 页内 tab 分页 + 卡片三行(v0.3.22 · 2026-09-15 用户三项要求)── // ① 「功能管理」→「能力管理」② 按 tab 切插件/技能各自操作 ③ 插件卡片中文用途描述 3 行 ok('zh 分区名已改为「能力管理」', /"section\.label": "能力管理"/.test(src)) ok('en 分区名已改为 Capability management', /"section\.label": "Capability management"/.test(src)) ok('旧名「功能管理」不再是任何 label', /"section\.label": "功能管理"/.test(src) === false) ok('tab 标签词条存在(cap.tabPlugins)', /"cap\.tabPlugins": "功能插件"/.test(src) && /"cap\.tabPlugins": "Feature plugins"/.test(src)) ok('顶部有 tab 条(复用 .pa-tabs + role=tablist)', /className: "pa-tabs",\s*\n\s*role: "tablist"/.test(src)) ok('两个页签(插件 / 技能)都渲染', /t\("cap\.tabPlugins"\)/.test(src) && /t\("msk\.group"\)/.test(src)) ok('页签带计数(.pa-tcnt)', /className: "pa-tcnt"/.test(src)) ok('页签有 aria-selected(可访问性)', /"aria-selected": isPlugins \? "true" : "false"/.test(src)) ok('两个页签各自可切换', /setTab\("plugins"\)/.test(src) && /setTab\("skills"\)/.test(src)) ok('插件块被 isPlugins 门控', /if \(isPlugins\) \{/.test(src)) ok('技能块被 !isPlugins 门控', /if \(!isPlugins\) \{/.test(src)) { // 三个区段(插件主体 / 技能主体 / 插件专属弹窗与提示 / 技能专属弹窗)都要有开有闭 const opens = (src.match(/if \(isPlugins\) \{/g) || []).length const notOpens = (src.match(/if \(!isPlugins\) \{/g) || []).length ok('门控数量成对(插件 2 处 / 技能 2 处)', opens === 2 && notOpens === 2, `-> isPlugins ${opens} / !isPlugins ${notOpens}`) } ok('插件卡片用途描述用 .bp-plugDesc', /className: "bp-plugDesc"/.test(src)) ok('.bp-plugDesc 有 -webkit-line-clamp:3', cssHas('bp-plugDesc', '-webkit-line-clamp:3')) ok('.bp-plugDesc 有显式 white-space:normal(否则继承单行截断)', cssHas('bp-plugDesc', 'white-space:normal')) ok('.bp-plugDesc 有 overflow:hidden', cssHas('bp-plugDesc', 'overflow:hidden')) ok('卡片描述仍挂 title(hover 看全文)', /className: "bp-plugDesc",[\s\S]{0,200}title: primary/.test(src)) ok('.bp-tabHead 存在(技能页工具行:左说明 / 右上传)', cssHas('bp-tabHead', 'justify-content:space-between')) // ── ⑦ 不得引用官方包/不得写 exports.default(R2 / R3 的机械兜底)── // ⚠️ 只断言**赋值形态**:本文件头部注释里就写着「绝不 exports.default」这句话, // 用 `includes()` 会被自己的注释判红(首跑实测踩到)。 ok('未出现 exports.default 赋值(R3)', /(?:^|\n)\s*(?:module\.)?exports\.default\s*=/.test(src) === false) console.log(failed === 0 ? '\n全部通过(我的技能:结构 + 版式 + 危险操作防线)' : `\n${failed} 项失败`) process.exit(failed === 0 ? 0 : 1)