refactor(scripts): retire the skin review lane from the PR reviewer

Skin, pet and community-index contributions are submitted and gated in dsh-skins, dsh-pet and dsh-community-plugins, and reject-non-content-pr.yml redirects PRs that declare them here, so pr-review.mjs could never see one: its skin detection matched packages/skins/skin-center/skins/<id>/, which no longer exists, and the visual lane it fed ran capture-previews (now writing into the satellite checkout) inside a PR worktree.

checkSkinChanges, checkCopyright, checkSkinPreviews, judgeVisualMetrics, skinVisualVerify and analyzePixels are gone, along with the result's visual field, the header note and their tests. The reviewer keeps the checks that still apply to what this repository accepts: size, secrets, emoji, CI files, lockfile, template evidence and the worktree gate sequence.

The retire-legacy-skin-gallery note named checkSkinPreviews as shipped reality; its fact now records that the reviewer carries no skin check, and the pair hashes are re-recorded.
This commit is contained in:
zhu1090093659
2026-09-24 09:45:20 +08:00
parent bd5205e191
commit e2cc4fdf9b
5 changed files with 5 additions and 236 deletions
@@ -1,5 +1,5 @@
# Bilingual-pair consistency record (docs/i18n.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with git hash-object.
2026-08-26-retire-legacy-skin-gallery.md: e5273cd788b54cf60c73f77aea24ba1233ecc97a
2026-08-26-retire-legacy-skin-gallery.zh.md: 2198a5a0e8d58e6e4967b42c1359a49cdae53ded
2026-08-26-retire-legacy-skin-gallery.md: 955aa37c7380a3b9af3d76ee8eeb4bf77a7d7bb6
2026-08-26-retire-legacy-skin-gallery.zh.md: 4572bd0c5e7f543e0812605f8d708b70dfa7de12
@@ -13,7 +13,7 @@ The original dsh-web skin gallery — the `gallery/` static site (index page, st
- Repoint the preview tooling at the market: `scripts/capture-previews` renders `market/dist/preview.html` from `market/dist/manifest.js` (it was already the same simulator and the same skin preview contract); `scripts/skins-montage.mjs` reads the market catalog for the README collage.
- Remove the gallery 301 maintenance: `scripts/deploy-market` no longer creates or updates the `gallery.dsh-market.com` redirect ruleset (`--skip-redirect` / `--redirect-only` are gone; the script is D1 + worker deploy only).
- The Cloudflare-side cleanup (Pages project `dsh-market-gallery`, the `gallery.dsh-market.com` DNS record, the zone 301 ruleset) is account-side and no repo code maintains it; the maintainer deletes it in the dashboard.
- `scripts/pr-review.mjs` drops the gallery page screenshot and the gallery-registry adaptation check; the skin PR check is now `checkSkinPreviews` (new skins must add `preview/{light,dark}.jpg`), and the preview copy path is fixed to `packages/skins/skin-center/skins/<id>/preview/<mode>.jpg` (it pointed at the pre-move path with a stale `.png` extension).
- `scripts/pr-review.mjs` drops the gallery page screenshot and the gallery-registry adaptation check, and carries no skin-PR check: skins are submitted and gated in dsh-skins, so the reviewer sees only the changes this repository accepts.
- Skin README pairs and the skin developer skill now instruct `pnpm market:build` + `open market/dist/preview.html?...` instead of gallery commands; `docs:write-pair` re-recorded the touched skins.
## Alternatives considered
@@ -13,7 +13,7 @@
- 预览工具改指向市场:`scripts/capture-previews` 渲染 `market/dist/preview.html` 并从 `market/dist/manifest.js` 取清单(本就是同一模拟器、同一皮肤预览契约);`scripts/skins-montage.mjs` 改读市场清单生成 README 一览图。
- 移除 gallery 301 维护:`scripts/deploy-market` 不再创建或更新 `gallery.dsh-market.com` 重定向规则集(`--skip-redirect` / `--redirect-only` 一并删除;脚本只剩 D1 + worker 部署)。
- Cloudflare 侧清理(Pages 项目 `dsh-market-gallery`、`gallery.dsh-market.com` DNS 记录、zone 301 规则集)属于账号侧,仓库代码不再维护;由维护者在控制台删除。
- `scripts/pr-review.mjs` 删除画廊页截图与 gallery 注册适配检查;皮肤 PR 检查改为 `checkSkinPreviews`(新皮肤必须提交 `preview/{light,dark}.jpg`),并修复预览图复制路径为 `packages/skins/skin-center/skins/<id>/preview/<mode>.jpg`(原指向迁移前的旧路径且扩展名已过期)。
- `scripts/pr-review.mjs` 删除画廊页截图与 gallery 注册适配检查,并不再带皮肤 PR 检查:皮肤在 dsh-skins 仓提交与把关,该工具只看本仓接收的改动。
- 皮肤 README 双语与皮肤开发者技能改为指引 `pnpm market:build` + `open market/dist/preview.html?...`;改动过的皮肤均已 `docs:write-pair` 重新记录配对。
## 备选方案
+1 -171
View File
@@ -23,9 +23,6 @@
* e2e 验证产物同区存放;定期用 --cleanup 或手动 rm -rf ~/remote-e2e 清理
* (工具启动时会自动 prune 已失效的 worktree 记录)。
*
* 皮肤 PR 额外:生成亮/暗预览截图(~/remote-e2e/e2e-<pr>/previews/),
* 像素指标自动判定过曝(太闪)与对比度不足(看不清),截图供视觉模型复核;
* 提醒作者声明贡献者版权,并检查新皮肤是否提供 preview/{light,dark}.jpg 预览图。
* --concurrency N 并行审核数(默认 2)
* --max-added N 新增行上限,超过即拒绝(默认 10000)
* --max-deleted N 删除行上限,超过即拒绝(默认 10000)
@@ -51,7 +48,7 @@
*/
import { spawnSync } from "node:child_process"
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { existsSync, mkdirSync, rmSync } from "node:fs"
import { homedir } from "node:os"
import { dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
@@ -304,66 +301,6 @@ export function checkLockfile(changes) {
}))
}
/** 皮肤变更识别:返回 { isSkin, skinIds }。仅源码类变更触发(README/preview/文档不算)。
v2 布局(issue #506):皮肤事实源是 skin-center 包内的资产目录。 */
export function checkSkinChanges(changes) {
const ids = new Set()
const SKIP_RE = /(README(\.zh)?\.md|README\.i18n\.yaml|preview\/|^docs\/)/i
for (const c of changes) {
if (SKIP_RE.test(c.path)) continue
const m = c.path.match(/^packages\/skins\/skin-center\/skins\/([^/]+)\//)
if (m) ids.add(m[1])
}
return { isSkin: ids.size > 0, skinIds: [...ids] }
}
/** 皮肤 PR 版权提醒:外部贡献者未在模板「贡献者版权声明」节声明时提示(warn)。 */
export function checkCopyright(prInfo, isSkin, repoOwner) {
if (!isSkin) return []
const isRepoOwner = prInfo.author && prInfo.author.login === repoOwner
if (isRepoOwner) return []
const section = readSection(prInfo.body || ``, `贡献者版权声明(Contributor Copyright)`)
if (section && section.trim()) return []
return [{
severity: `warn`, rule: `copyright`,
message: `皮肤 PR 请提醒作者在 PR 模板「贡献者版权声明(Contributor Copyright)」节声明贡献者版权(在 README 版权表追加一行)`,
}]
}
/** 新皮肤市场预览检查:缺 preview/{light,dark}.jpg 即警告(市场清单由 market-build 自动派生,无需手改产物)。 */
export function checkSkinPreviews(changes, skinIds) {
if (!skinIds.length) return []
const findings = []
for (const id of skinIds) {
const isNew = changes.some((c) => c.status === `A` &&
c.path.startsWith(`packages/skins/skin-center/skins/` + id + `/`))
if (!isNew) continue
for (const mode of [`light`, `dark`]) {
const hasPreview = changes.some((c) =>
c.path === `packages/skins/skin-center/skins/` + id + `/preview/` + mode + `.jpg`)
if (!hasPreview) {
findings.push({ severity: `warn`, rule: `preview`, message: `新皮肤 ` + id + ` 未提供 ` + mode + ` 预览图:请运行 node scripts/capture-previews ` + id + ` 并提交 preview/` + mode + `.jpg` })
}
}
}
return findings
}
/** 视觉指标阈值判定:过曝(太闪)/ 对比度不足(看不清)。返回 warn findings。 */
export function judgeVisualMetrics(metrics) {
const findings = []
for (const m of metrics || []) {
const name = m.file || `?`
if (m.avgLuma > 215) {
findings.push({ severity: `warn`, rule: `visual`, message: name + ` 亮度过高(avgLuma ` + m.avgLuma + `),可能太闪` })
}
if (m.hiPct > 40) {
findings.push({ severity: `warn`, rule: `visual`, message: name + ` 有 ` + m.hiPct + `% 像素接近纯白,可能过曝` })
}
if (m.stdLuma < 20) {
findings.push({ severity: `warn`, rule: `visual`, message: name + ` 对比度过低(std ` + m.stdLuma + `),可能看不清` })
}
}
return findings
}
/** 提取 PR body 中某个 ## 小节的内容(去除 HTML 注释)。 */
export function readSection(body, label) {
const escaped = label.replace(/[.*+?^${{}()|[\]\\]/g, `\\$&`)
@@ -698,7 +635,6 @@ export function collectPrDiff(repoRoot, prInfo, maxAdded = DEFAULT_MAX_ADDED, ma
/** 静态审核:纯数据 -> findings。规模超限直接拒绝,不做内容扫描与模板检查。 */
export function staticReview(prInfo, diff, opts, repoOwner) {
const sizeFindings = checkSize(diff.stat, opts.maxAdded, opts.maxDeleted)
const skin = checkSkinChanges(diff.allChanges)
if (sizeFindings.some((f) => f.severity === `reject`)) {
return [...sizeFindings, ...checkForbiddenFiles(diff.addedFiles, diff.sizes, opts.maxFileBytes)]
}
@@ -713,8 +649,6 @@ export function staticReview(prInfo, diff, opts, repoOwner) {
...checkLockfile(diff.allChanges),
...checkTemplate(prInfo, repoOwner),
...checkCommits(prInfo.commits),
...checkCopyright(prInfo, skin.isSkin, repoOwner),
...checkSkinPreviews(diff.allChanges, skin.skinIds),
]
}
@@ -763,91 +697,6 @@ export function buildVerify(repoRoot, number, headRef, worktreeRoot) {
return results
}
/** 皮肤视觉验证:在已构建的 worktree 里生成预览截图并复制到 e2e 工作区。 */
export function skinVisualVerify(repoRoot, number, skinIds, worktreeRoot, workdir) {
const outDir = join(worktreeRoot, `e2e-` + number, `previews`)
mkdirSync(outDir, { recursive: true })
const previews = []
try {
const res = run(`node`, [`scripts/capture-previews`, ...skinIds], { cwd: workdir, timeout: 10 * 60 * 1000, maxBuffer: 64 * 1024 * 1024 })
if (res.status !== 0) {
return { error: `预览截图失败: ` + res.stderr.trim().split(`\n`).slice(-3).join(` `), previews }
}
for (const id of skinIds) {
for (const mode of [`light`, `dark`]) {
const src = join(workdir, `packages`, `skins`, `skin-center`, `skins`, id, `preview`, mode + `.jpg`)
if (existsSync(src)) {
const dst = join(outDir, id + `-` + mode + `.png`)
copyFileSync(src, dst)
previews.push(dst)
}
}
}
if (!previews.length) return { error: `未找到预览截图(确认皮肤包已构建且含 lib/client.js)`, previews }
// 像素指标分析:亮度(太闪)/ 对比度(看不清),结果写 metrics.json
const metrics = analyzePixels(workdir, previews)
writeFileSync(join(outDir, `metrics.json`), JSON.stringify(metrics, null, 2))
return { previews, metrics, findings: judgeVisualMetrics(metrics) }
} catch (e) {
return { error: String(e.message), previews }
}
}
/** 用 playwright 在页面里解码截图并统计亮度/对比度/饱和度指标。 */
function analyzePixels(workdir, previews) {
const script = [
"const { chromium } = require('playwright');",
"const fs = require('fs');",
"(async () => {",
" const b = await chromium.launch()",
" const page = await b.newPage()",
" await page.setContent('<html><body><img id=\"i\" style=\"display:none\"></body></html>')",
" const results = []",
" for (const p of process.argv.slice(2)) {",
" const b64 = fs.readFileSync(p).toString('base64')",
" const r = await page.evaluate(async (src) => {",
" const img = document.getElementById('i')",
" img.src = src",
" await img.decode().catch(() => {})",
" const c = document.createElement('canvas')",
" c.width = img.naturalWidth || 1; c.height = img.naturalHeight || 1",
" const ctx = c.getContext('2d')",
" ctx.drawImage(img, 0, 0)",
" let data",
" try { data = ctx.getImageData(0, 0, c.width, c.height).data } catch (e) { return { error: String(e).slice(0, 120) } }",
" let sum = 0, sumsq = 0, hi = 0, lo = 0, satSum = 0",
" const n = data.length / 4",
" for (let i = 0; i < data.length; i += 4) {",
" const rr = data[i], g = data[i + 1], bl = data[i + 2]",
" const y = 0.299 * rr + 0.587 * g + 0.114 * bl",
" sum += y; sumsq += y * y",
" if (y > 235) hi++",
" if (y < 20) lo++",
" const mx = Math.max(rr, g, bl), mn = Math.min(rr, g, bl)",
" satSum += mx === 0 ? 0 : (mx - mn) / mx",
" }",
" const avg = sum / n",
" return { w: c.width, h: c.height, avgLuma: Math.round(avg * 10) / 10, stdLuma: Math.round(Math.sqrt(sumsq / n - avg * avg) * 10) / 10, hiPct: Math.round(hi / n * 1000) / 10, loPct: Math.round(lo / n * 1000) / 10, satAvg: Math.round(satSum / n * 1000) / 10 }",
" }, 'data:image/png;base64,' + b64)",
" results.push({ file: p.split('/').pop(), ...r })",
" }",
" console.log('PIXRESULT' + JSON.stringify(results))",
" await b.close()",
"})()",
].join(`\n`)
try {
const shotFile = join(workdir, `.pr-review-pixel-shot.cjs`)
writeFileSync(shotFile, script)
try {
const res = run(`node`, [shotFile, ...previews], { cwd: workdir, timeout: 180 * 1000, maxBuffer: 16 * 1024 * 1024 })
const m = res.stdout.match(/PIXRESULT(\[.*\])/)
if (m) return JSON.parse(m[1])
} finally {
rmSync(shotFile, { force: true })
}
} catch { /* 指标分析失败不阻塞 */ }
return []
}
/** 清理工作区:移除其下全部 worktree、删除目录与遗留 refs。返回移除数。 */
export function cleanupWorktrees(repoRoot, worktreeRoot) {
const removed = []
@@ -943,12 +792,6 @@ async function reviewPr(number, prInfo, ctx) {
? { failures: [] }
: buildVerify(repoRoot, number, headRef, opts.worktreeRoot || DEFAULT_WORKTREE_ROOT)
}
const skin = checkSkinChanges(diff.allChanges)
let visual = null
if (skin.isSkin && buildResult && buildResult.workdir) {
visual = skinVisualVerify(repoRoot, number, skin.skinIds, worktreeRoot, buildResult.workdir)
if (visual && visual.findings) findings.push(...visual.findings)
}
const verdict = finalVerdict(findings, buildResult)
const result = {
number, title: prInfo.title, url: prInfo.url,
@@ -964,7 +807,6 @@ async function reviewPr(number, prInfo, ctx) {
},
findings,
build: buildResult ? { failures: buildResult.failures, workdir: buildResult.workdir || null, skipped: opts.skipBuild, reused: buildResult.reused || false } : null,
visual,
}
if (verdict === `FAIL`) result.reason = `构建门禁失败: ` + buildResult.failures.join(`, `)
return result
@@ -1006,18 +848,6 @@ function formatHuman(results, opts) {
lines.push(c(`32`, ` [通过] worktree 构建与全部门禁通过`))
}
if (r.build && r.build.workdir && !r.build.skipped) lines.push(` worktree: ` + r.build.workdir + (r.build.reused ? `(复用)` : ``))
if (r.visual && r.visual.previews.length) {
lines.push(` [视觉] 皮肤预览截图 ` + r.visual.previews.length + ` 张(light/dark),像素指标与截图见 ` + (r.visual.metrics ? `metrics.json` : ``))
if (r.visual.metrics && r.visual.metrics.length) {
for (const m of r.visual.metrics) {
lines.push(` ` + m.file + ` avg=` + m.avgLuma + ` std=` + m.stdLuma + ` 过曝=` + m.hiPct + `% 饱和度=` + m.satAvg)
}
}
for (const p of r.visual.previews) lines.push(` ` + p)
}
if (r.visual && r.visual.error) {
lines.push(c(`33`, ` [视觉] ` + r.visual.error))
}
lines.push(``)
}
const summary = results.map((r) => {
-61
View File
@@ -4,7 +4,6 @@ import {
parseNumstat, parseNameStatus, addedLinesFromDiff,
checkSize, checkForbiddenFiles, checkSecrets, checkEmoji,
checkWorkflowChanges, checkLockfile, checkTemplate, checkCommits,
checkSkinChanges, checkCopyright, checkSkinPreviews, judgeVisualMetrics,
finalVerdict, parseArgs, fmtBytes, DEFAULT_MAX_ADDED,
} from "./pr-review.mjs"
@@ -355,66 +354,6 @@ test(`提交信息检查`, () => {
assert.ok(bad.some((x) => x.severity === `reject` && x.rule === `emoji`))
})
// ---------------------------------------------------------------- 皮肤识别与版权
test(`皮肤变更识别:源码类命中,README 与 skin-center 排除`, () => {
const f1 = checkSkinChanges([
{ status: `A`, path: `packages/skins/skin-center/skins/xp/skin.css` },
{ status: `A`, path: `packages/skins/skin-center/skins/xp/skin.json` },
])
assert.deepEqual(f1, { isSkin: true, skinIds: [`xp`] })
const f2 = checkSkinChanges([
{ status: `M`, path: `packages/skins/skin-center/skins/xp/README.md` },
{ status: `M`, path: `packages/skins/skin-center/skins/xp/preview/light.jpg` },
{ status: `M`, path: `docs/development.md` },
])
assert.deepEqual(f2, { isSkin: false, skinIds: [] })
const f3 = checkSkinChanges([{ status: `M`, path: `packages/skins/skin-center/src/routes.ts` }])
assert.equal(f3.isSkin, false)
})
test(`版权提醒:外部贡献者皮肤 PR 未声明时 warn,已声明或仓库所有者豁免`, () => {
const pr = { body: `## 摘要(Summary)\n测试`, author: { login: `someone` } }
const f1 = checkCopyright(pr, true, `owner`)
assert.equal(f1.length, 1)
assert.equal(f1[0].severity, `warn`)
assert.equal(f1[0].rule, `copyright`)
const declared = { body: `## 贡献者版权声明(Contributor Copyright)\n- [x] 已声明\n| 包 | 来源 | 版权 |`, author: { login: `someone` } }
assert.equal(checkCopyright(declared, true, `owner`).length, 0)
assert.equal(checkCopyright(pr, false, `owner`).length, 0)
assert.equal(checkCopyright(pr, true, `someone`).length, 0)
})
test(`市场预览:新皮肤缺 preview/{light,dark}.jpg 时警告,齐图或存量皮肤豁免`, () => {
const base = [
{ status: `A`, path: `packages/skins/skin-center/skins/xp/skin.css` },
]
const f1 = checkSkinPreviews(base, [`xp`])
assert.equal(f1.length, 2)
assert.ok(f1.every((x) => x.severity === `warn` && x.rule === `preview`))
const complete = [
{ status: `A`, path: `packages/skins/skin-center/skins/xp/skin.css` },
{ status: `A`, path: `packages/skins/skin-center/skins/xp/preview/light.jpg` },
{ status: `A`, path: `packages/skins/skin-center/skins/xp/preview/dark.jpg` },
]
assert.equal(checkSkinPreviews(complete, [`xp`]).length, 0)
const modified = [{ status: `M`, path: `packages/skins/skin-center/skins/xp/skin.css` }]
assert.equal(checkSkinPreviews(modified, [`xp`]).length, 0)
})
test(`视觉指标判定:过曝与对比度不足警告`, () => {
const f1 = judgeVisualMetrics([
{ file: `xp-light.png`, avgLuma: 219.9, hiPct: 76.6, stdLuma: 60.3 },
])
assert.equal(f1.length, 2)
assert.ok(f1.every((x) => x.severity === `warn` && x.rule === `visual`))
assert.ok(f1.some((x) => x.message.includes(`太闪`)))
const ok = judgeVisualMetrics([{ file: `a-dark.png`, avgLuma: 55, hiPct: 3, stdLuma: 52 }])
assert.equal(ok.length, 0)
const lowContrast = judgeVisualMetrics([{ file: `b-light.png`, avgLuma: 120, hiPct: 1, stdLuma: 12 }])
assert.equal(lowContrast.length, 1)
assert.ok(lowContrast[0].message.includes(`看不清`))
const overexposed = judgeVisualMetrics([{ file: `xp-light.jpg`, avgLuma: 250, hiPct: 95, stdLuma: 10 }])
assert.ok(overexposed.some((x) => x.message.includes(`接近纯白`)))
})
// ---------------------------------------------------------------- finalVerdict
test(`verdict 优先级`, () => {