cardbuddy 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +7 -3
  2. package/dist/cli.js +4 -4
  3. package/dist/config.js +7 -7
  4. package/dist/init.js +15 -4
  5. package/dist/portGuard.js +2 -2
  6. package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
  7. package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
  8. package/dist/preview-editor/index.html +2 -2
  9. package/dist/standalone.js +14 -6
  10. package/dist/vendored/card-editor/api.js +6 -6
  11. package/dist/vendored/mcp/client.mjs +53 -3
  12. package/dist/vendored/mcp/tools.mjs +17 -1
  13. package/dist/vendored/server/core/ai-chat.js +1443 -271
  14. package/dist/vendored/server/core/cardFiles.js +271 -39
  15. package/dist/vendored/server/core/cardImportMeta.js +16 -0
  16. package/dist/vendored/server/core/chatHistory.js +122 -0
  17. package/dist/vendored/server/core/index.js +10 -3
  18. package/dist/vendored/server/core/turnJournal.js +386 -0
  19. package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
  20. package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
  21. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
  22. package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
  23. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
  24. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
  26. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
  27. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
  28. package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
  29. package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
  30. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
  31. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
  33. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
  34. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
  36. package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
  37. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
  38. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
  41. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
  42. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
  43. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
  44. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
  45. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
  46. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
  47. package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
  49. package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
  50. package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
  51. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
  52. package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
  53. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
  54. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
  55. package/dist/vendored/tools/finmall-env.mjs +6 -6
  56. package/package.json +1 -1
  57. package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
  58. package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
  59. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
  60. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
  61. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // 轮间对比:PASS→FAIL 回归检测、FAIL→PASS 修复确认、指标 delta。
3
3
  // 用途:提交/落盘前门禁(存在回归时 exit 1)、训练报告中的轮次演进证据。
4
- // 用法:node round_diff.mjs --from training/runs/r170 --to training/runs/r171 [--allow-regressions]
4
+ // 用法:node round_diff.mjs --from training/runs/r170 --to training/runs/r171 [--allow-regressions] [--json out.json]
5
5
  // --from/--to 也接受压缩锚点文件(training/anchors/r176.json),干净克隆无 training/runs 时
6
- // 仍可与冻结锚点对账:node round_diff.mjs --from training/anchors/r176.json --to training/runs/r202
6
+ // 仍可与冻结锚点对账:node round_diff.mjs --from training/anchors/r176.json --to training/runs/r262
7
+ // 读取与对比口径统一走 lib/runs.mjs(score_round / plan_next_round 同源)。
7
8
  import fs from 'node:fs'
8
9
  import path from 'node:path'
10
+ import { compareRounds, loadRunReports, reportsDirOf } from './lib/runs.mjs'
9
11
 
10
12
  function parseArgs(argv) {
11
- const out = { from: '', to: '', allowRegressions: false }
13
+ const out = { from: '', to: '', allowRegressions: false, json: '' }
12
14
  const camel = (raw) => raw.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase())
13
15
  for (let index = 0; index < argv.length; index += 1) {
14
16
  const token = argv[index]
@@ -21,113 +23,92 @@ function parseArgs(argv) {
21
23
  return out
22
24
  }
23
25
 
24
- function loadReports(runDir) {
25
- const resolved = path.resolve(runDir)
26
- if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
27
- const anchor = JSON.parse(fs.readFileSync(resolved, 'utf8'))
28
- const list = Array.isArray(anchor.cases) ? anchor.cases : Object.values(anchor.cases || {})
29
- const map = new Map()
30
- for (const report of list) if (report && report.caseId) map.set(report.caseId, report)
31
- if (!map.size) throw new Error(`No cases in anchor file ${resolved}`)
32
- return map
33
- }
34
- const reportDir = path.join(resolved, 'reports')
35
- if (!fs.existsSync(reportDir)) throw new Error(`No reports at ${reportDir}`)
36
- const map = new Map()
37
- for (const file of fs.readdirSync(reportDir)) {
38
- if (!file.endsWith('.json')) continue
39
- if (/summary|skipped|review|semantic/.test(file)) continue
40
- const report = JSON.parse(fs.readFileSync(path.join(reportDir, file), 'utf8'))
41
- if (report.caseId) map.set(report.caseId, report)
42
- }
43
- return map
44
- }
45
-
46
26
  const args = parseArgs(process.argv.slice(2))
47
27
  if (!args.from || !args.to) throw new Error('--from and --to are required')
48
28
 
49
- const fromReports = loadReports(args.from)
50
- const toReports = loadReports(args.to)
29
+ const fromReports = loadRunReports(args.from)
30
+ const toReports = loadRunReports(args.to)
31
+ const comparison = compareRounds(fromReports, toReports)
51
32
 
52
- const metric = (report, selector) => selector(report) ?? null
53
- const selectors = {
54
- fidelity: (r) => r.fidelity?.overall,
55
- pixel: (r) => r.pixel?.ratio,
56
- position: (r) => r.fidelity?.position,
57
- structure: (r) => r.pixel?.structure,
58
- }
59
- const avg = (values) => {
60
- const nums = values.filter((v) => typeof v === 'number')
61
- return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null
62
- }
63
- const fmt = (v, pct = true) => v == null ? 'N/A' : pct ? (v * 100).toFixed(1) + '%' : v.toFixed(2)
33
+ const pct = (value) => value == null ? 'N/A' : `${(value * 100).toFixed(1)}%`
34
+ const pp = (value) => value == null ? 'N/A' : `${value > 0 ? '+' : ''}${(value * 100).toFixed(1)}pp`
35
+ const deltaOf = (before, after) =>
36
+ typeof before === 'number' && typeof after === 'number' ? after - before : null
64
37
 
65
- const fixed = [] // fail -> pass
66
- const regressed = [] // pass -> fail
67
- const stillFailing = []
68
- const added = []
69
- const removed = []
70
- const metricDeltas = []
38
+ console.log(`══ 轮间对比 ${path.basename(String(args.from))} → ${path.basename(String(args.to))} ══`)
39
+ console.log(`案例: ${comparison.from.total} → ${comparison.to.total}(新增 ${comparison.added.length} / 移除 ${comparison.removed.length} / 共享 ${comparison.sharedCount})`)
40
+ console.log('')
41
+ console.log('── 指标 delta(均值)──')
42
+ console.log(` passRate: ${pct(comparison.from.passRate)} → ${pct(comparison.to.passRate)}`)
43
+ for (const key of ['fidelity', 'pixel', 'position', 'structure', 'components']) {
44
+ const before = comparison.from[key]
45
+ const after = comparison.to[key]
46
+ console.log(` ${key.padEnd(10)} ${pct(before)} → ${pct(after)} (${pp(deltaOf(before, after))})`)
47
+ }
48
+ console.log('')
49
+ console.log('── 状态迁移 ──')
50
+ console.log(` 修复 fail→pass: ${comparison.fixed.length}`)
51
+ for (const item of comparison.fixed) console.log(` + ${item.caseId} ${item.name}`)
52
+ console.log(` 回归 pass→fail: ${comparison.regressed.length}`)
53
+ for (const item of comparison.regressed) console.log(` ! ${item.caseId} ${item.name}`)
54
+ console.log(` 持续失败: ${comparison.stillFailing.length}`)
55
+ for (const item of comparison.stillFailing.slice(0, 10)) console.log(` - ${item.caseId} ${item.name}`)
71
56
 
72
- for (const [caseId, toReport] of toReports) {
73
- const fromReport = fromReports.get(caseId)
74
- if (!fromReport) { added.push(caseId); continue }
75
- if (fromReport.passed && !toReport.passed) {
76
- regressed.push({ caseId, name: toReport.name || fromReport.name || '' })
77
- } else if (!fromReport.passed && toReport.passed) {
78
- fixed.push({ caseId, name: toReport.name || '' })
79
- } else if (!fromReport.passed && !toReport.passed) {
80
- stillFailing.push({ caseId, name: toReport.name || '' })
81
- }
82
- const delta = {}
83
- for (const [key, pick] of Object.entries(selectors)) {
84
- const before = metric(fromReport, pick)
85
- const after = metric(toReport, pick)
86
- if (typeof before === 'number' && typeof after === 'number') delta[key] = after - before
57
+ const worst = comparison.metricDeltas
58
+ .filter((item) => typeof item.delta.fidelity === 'number')
59
+ .sort((a, b) => a.delta.fidelity - b.delta.fidelity)
60
+ .slice(0, 5)
61
+ .filter((item) => item.delta.fidelity < -0.005)
62
+ if (worst.length) {
63
+ console.log('')
64
+ console.log('── 还原度下滑 Top5(仍通过的也需关注)──')
65
+ for (const item of worst) {
66
+ console.log(` ${item.caseId} fidelity ${pp(item.delta.fidelity)} pixel ${pp(item.delta.pixel ?? 0)}`)
87
67
  }
88
- if (Object.keys(delta).length) metricDeltas.push({ caseId, delta })
89
- }
90
- for (const caseId of fromReports.keys()) {
91
- if (!toReports.has(caseId)) removed.push(caseId)
92
68
  }
93
69
 
94
- const fromList = [...fromReports.values()]
95
- const toList = [...toReports.values()]
96
- console.log(`══ 轮间对比 ${path.basename(args.from)} → ${path.basename(args.to)} ══`)
97
- console.log(`案例: ${fromReports.size} → ${toReports.size}(新增 ${added.length} / 移除 ${removed.length})`)
98
- console.log('')
99
- console.log('── 指标 delta(均值)──')
100
- console.log(` passRate: ${fmt(avg(fromList.map((r) => r.passed ? 1 : 0)))} → ${fmt(avg(toList.map((r) => r.passed ? 1 : 0)))}`)
101
- for (const [key, pick] of Object.entries(selectors)) {
102
- const before = avg(fromList.map((r) => metric(r, pick)))
103
- const after = avg(toList.map((r) => metric(r, pick)))
104
- const sign = after != null && before != null && after > before ? '+' : ''
105
- console.log(` ${key.padEnd(9)} ${fmt(before)} → ${fmt(after)} (${sign}${after != null && before != null ? ((after - before) * 100).toFixed(1) : 'N/A'}pp)`)
70
+ // 均值退化清单:晋级门要求 fidelity/pixel/position/structure 均值不低于基线轮
71
+ const degraded = []
72
+ for (const [key, betterWhenHigher] of [['fidelity', true], ['position', true], ['structure', true], ['components', true], ['pixel', false]]) {
73
+ const delta = deltaOf(comparison.from[key], comparison.to[key])
74
+ if (delta == null) continue
75
+ const worse = betterWhenHigher ? delta < -1e-9 : delta > 1e-9
76
+ if (worse) degraded.push({ metric: key, delta: Number(delta.toFixed(5)) })
77
+ }
78
+ if (degraded.length) {
79
+ console.log('')
80
+ console.log('── 均值退化(晋级门「不低于锚点」不满足)──')
81
+ for (const item of degraded) console.log(` ${item.metric} ${pp(item.delta)}`)
106
82
  }
107
- console.log('')
108
- console.log(`── 状态迁移 ──`)
109
- console.log(` 修复 fail→pass: ${fixed.length}`)
110
- for (const item of fixed) console.log(` + ${item.caseId} ${item.name}`)
111
- console.log(` 回归 pass→fail: ${regressed.length}`)
112
- for (const item of regressed) console.log(` ! ${item.caseId} ${item.name}`)
113
- console.log(` 持续失败: ${stillFailing.length}`)
114
- for (const item of stillFailing.slice(0, 10)) console.log(` - ${item.caseId} ${item.name}`)
115
83
 
116
- if (metricDeltas.length) {
117
- const worst = [...metricDeltas]
118
- .filter((item) => typeof item.delta.fidelity === 'number')
119
- .sort((a, b) => a.delta.fidelity - b.delta.fidelity)
120
- .slice(0, 5)
121
- .filter((item) => item.delta.fidelity < -0.005)
122
- if (worst.length) {
123
- console.log('')
124
- console.log('── 还原度下滑 Top5(仍通过的也需关注)──')
125
- for (const item of worst) console.log(` ${item.caseId} fidelity ${(item.delta.fidelity * 100).toFixed(1)}pp pixel ${(item.delta.pixel ?? 0) >= 0 ? '+' : ''}${((item.delta.pixel ?? 0) * 100).toFixed(1)}pp`)
84
+ // --json 支持裸开关(不带路径):默认落进 --to 轮的 reports/,避免把产物写进仓库根目录。
85
+ const jsonTarget = args.json === true
86
+ ? path.join(reportsDirOf(args.to), 'round-diff.json')
87
+ : (typeof args.json === 'string' && args.json ? args.json : '')
88
+ if (jsonTarget) {
89
+ const payload = {
90
+ from: String(args.from),
91
+ to: String(args.to),
92
+ metrics: { from: comparison.from, to: comparison.to },
93
+ degraded,
94
+ fixed: comparison.fixed,
95
+ regressed: comparison.regressed,
96
+ stillFailing: comparison.stillFailing,
97
+ added: comparison.added,
98
+ removed: comparison.removed,
99
+ promotionBlockers: {
100
+ regressions: comparison.regressed.length,
101
+ degradedMetrics: degraded.map((item) => item.metric),
102
+ },
126
103
  }
104
+ fs.mkdirSync(path.dirname(path.resolve(jsonTarget)), { recursive: true })
105
+ fs.writeFileSync(path.resolve(jsonTarget), JSON.stringify(payload, null, 2) + '\n')
106
+ console.log('')
107
+ console.log(`JSON: ${path.resolve(jsonTarget)}`)
127
108
  }
128
109
 
129
- if (regressed.length && !args.allowRegressions) {
110
+ if (comparison.regressed.length && !args.allowRegressions) {
130
111
  console.log('')
131
- console.log(`REGRESSION: ${regressed.length} 例 PASS→FAIL,退出码 1(--allow-regressions 可豁免)`)
112
+ console.log(`REGRESSION: ${comparison.regressed.length} 例 PASS→FAIL,退出码 1(--allow-regressions 可豁免)`)
132
113
  process.exit(1)
133
114
  }
@@ -13,15 +13,22 @@ function findPython() {
13
13
  }
14
14
  import fs from 'node:fs'
15
15
  import path from 'node:path'
16
- import { spawnSync } from 'node:child_process'
16
+ import { pathToFileURL } from 'node:url'
17
17
  import { PNG } from 'pngjs'
18
18
 
19
19
  const repoRoot = path.resolve(import.meta.dirname, '../../..')
20
20
 
21
+ /**
22
+ * 绝对路径动态 import:Windows 下裸盘符路径会被 ESM 加载器当成 `d:` 协议 URL,
23
+ * 抛 ERR_UNSUPPORTED_ESM_URL_SCHEME(训练轮在启动阶段就整体失败)。
24
+ */
25
+ const importAbs = (target) => import(pathToFileURL(target).href)
26
+
21
27
  function parseArgs(argv) {
22
28
  const out = {
23
29
  dataset: '', round: '', strategy: 'v3', limit: 0, splits: 'train,dev,eval', only: '',
24
- onlyFailed: '', noCorrect: false,
30
+ onlyFailed: '', noCorrect: false, focus: '', focusBelow: 0.85, focusLimit: 0,
31
+ resume: false, inherit: '', noSummary: false,
25
32
  }
26
33
  const camel = (raw) => raw.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase())
27
34
  for (let index = 0; index < argv.length; index += 1) {
@@ -144,7 +151,13 @@ async function runMechanicalAutofix(schemaPath) {
144
151
  return { repairedNodes: 0, repairs: [], autofixChanged: !autofix.error && autofixOutput.includes('修复'), autofixOutput }
145
152
  }
146
153
 
147
- import { imageFidelity, positionScore, textOverflowScore, duplicationScore, fidelityScore, fontMaskWiden } from './lib/scoring.mjs'
154
+ import {
155
+ imageFidelity, positionScore, textOverflowScore, duplicationScore, fidelityScore, fontMaskWiden,
156
+ loadFontMetrics,
157
+ evaluatePassGate, attributeFailure, failureSignature, exclusionCoverage,
158
+ errorKind,
159
+ } from './lib/scoring.mjs'
160
+ import { loadRunReports } from './lib/runs.mjs'
148
161
 
149
162
 
150
163
  function sourceChanges() {
@@ -382,21 +395,56 @@ function expectedComponents(card) {
382
395
  if (width <= 0 || height <= 0) return 0
383
396
  return (width * height) / Math.min((ar.width || 1) * (ar.height || 1), (br.width || 1) * (br.height || 1))
384
397
  }
385
- const controlShapes = (card?.elements || []).filter((element) => {
398
+ const qualRowShell = (element) => {
386
399
  const rect = rectOf(element)
387
400
  return element.type === 'shape'
388
401
  && (rect.width || 0) >= 80 && (rect.height || 0) >= 30 && (rect.height || 0) <= 80
389
402
  && (element.style?.radius?.borderRadius || 0) >= 10
390
- && (() => {
391
- const inner = (card?.elements || []).filter((child) => {
392
- if (child.type !== 'text' || !(child.text || child.value)) return false
393
- const childRect = rectOf(child)
394
- return (childRect.x || 0) >= (rect.x || 0) && (childRect.y || 0) >= (rect.y || 0)
395
- && ((childRect.x || 0) + (childRect.width || 0)) <= ((rect.x || 0) + (rect.width || 0))
396
- && ((childRect.y || 0) + (childRect.height || 0)) <= ((rect.y || 0) + (rect.height || 0))
397
- })
398
- return inner.length > 0 && new Set(inner.map((child) => Math.round((rectOf(child).y || 0) / 18))).size <= 1
399
- })()
403
+ }
404
+ // 同尺寸壳竖直重复堆叠 = 列表/入口行栈(非按钮):按钮不会同尺寸成栈重复出现。
405
+ const stackedRowShells = new Set()
406
+ {
407
+ const bySize = new Map()
408
+ for (const element of (card?.elements || [])) {
409
+ if (!qualRowShell(element)) continue
410
+ const r = rectOf(element)
411
+ const key = `${Math.round(r.width || 0)}x${Math.round(r.height || 0)}`
412
+ if (!bySize.has(key)) bySize.set(key, [])
413
+ bySize.get(key).push(element)
414
+ }
415
+ for (const group of bySize.values()) {
416
+ if (group.length < 2) continue
417
+ const sorted = group.sort((a, b) => (rectOf(a).y || 0) - (rectOf(b).y || 0))
418
+ const gaps = sorted.slice(1).map((el, i) => (rectOf(el).y || 0)
419
+ - ((rectOf(sorted[i]).y || 0) + (rectOf(sorted[i]).height || 0)))
420
+ if (gaps.every((g) => g >= 0 && g <= 80)) sorted.forEach((el) => stackedRowShells.add(el))
421
+ }
422
+ }
423
+ const controlShapes = (card?.elements || []).filter((element) => {
424
+ const rect = rectOf(element)
425
+ if (!qualRowShell(element)) return false
426
+ const inner = (card?.elements || []).filter((child) => {
427
+ if (child.type !== 'text' || !(child.text || child.value)) return false
428
+ const childRect = rectOf(child)
429
+ return (childRect.x || 0) >= (rect.x || 0) && (childRect.y || 0) >= (rect.y || 0)
430
+ && ((childRect.x || 0) + (childRect.width || 0)) <= ((rect.x || 0) + (rect.width || 0))
431
+ && ((childRect.y || 0) + (childRect.height || 0)) <= ((rect.y || 0) + (rect.height || 0))
432
+ })
433
+ if (!(inner.length > 0 && new Set(inner.map((child) => Math.round((rectOf(child).y || 0) / 18))).size <= 1)) return false
434
+ // 左锚定单文本 + 右端小亲和件(箭头/勾选)+ 同尺寸成栈 = 导航入口行(列表行),
435
+ // 不是按钮/输入控件:误判会把行内文本与箭头从期望组件中剔除,组件相似度系统性低估正确渲染。
436
+ const leftAnchored = inner.length === 1
437
+ && ((rectOf(inner[0]).x || 0) - (rect.x || 0)) <= (rect.width || 0) * 0.2
438
+ const rightAffordance = (card?.elements || []).some((e2) => {
439
+ if (!['slice', 'symbol', 'shape'].includes(e2.type)) return false
440
+ const r2 = rectOf(e2)
441
+ const w = r2.width || 0
442
+ const h = r2.height || 0
443
+ if (w < 4 || w > 32 || h < 4 || h > 32) return false
444
+ return (r2.x || 0) + w >= (rect.x || 0) + (rect.width || 0) - 28
445
+ && Math.abs(((r2.y || 0) + h / 2) - ((rect.y || 0) + (rect.height || 0) / 2)) <= (rect.height || 0) / 2
446
+ })
447
+ return !(leftAnchored && rightAffordance && stackedRowShells.has(element))
400
448
  })
401
449
  const insideControl = (element) => {
402
450
  const center = centerOf(element)
@@ -596,7 +644,14 @@ async function main() {
596
644
  const cardsDir = path.join(runDir, 'cards')
597
645
  const slicesDir = path.join(runDir, 'slices')
598
646
  const reportDir = path.join(runDir, 'reports')
599
- fs.rmSync(runDir, { recursive: true, force: true })
647
+ // --resume:保留既有轮目录续跑(渲染假死 / kill -9 之后不必整轮重来,r202 的实际痛点);
648
+ // 默认仍是干净重建,避免上一轮残留报告混进本轮统计
649
+ if (args.resume) {
650
+ if (!fs.existsSync(reportDir)) throw new Error(`--resume: 既有轮目录不存在 ${runDir}`)
651
+ console.log(`[resume] 续跑 ${args.round}:已产出报告的案例跳过`)
652
+ } else {
653
+ fs.rmSync(runDir, { recursive: true, force: true })
654
+ }
600
655
  fs.mkdirSync(cardsDir, { recursive: true })
601
656
  fs.mkdirSync(reportDir, { recursive: true })
602
657
  process.env.DSL_CARD_CARDS_DIR = cardsDir
@@ -606,10 +661,10 @@ async function main() {
606
661
  if (args.strategy === 'geo') {
607
662
  console.warn('[WARN] --strategy geo 已废弃(geo 生成器已删除),回退 v3')
608
663
  }
609
- const generatorFile = repoRoot + '/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs'
610
- const { generate } = await import(generatorFile)
611
- const { createRenderSession } = await import(repoRoot +
612
- '/skills/dsl-card-generator/scripts/render_card.mjs')
664
+ const generatorFile = path.join(repoRoot, 'skills', 'dsl-card-generator', 'scripts', 'spec_to_schema_v3.mjs')
665
+ const { generate } = await importAbs(generatorFile)
666
+ const { createRenderSession } = await importAbs(
667
+ path.join(repoRoot, 'skills', 'dsl-card-generator', 'scripts', 'render_card.mjs'))
613
668
 
614
669
  const splits = String(args.splits).split(',').filter(Boolean)
615
670
  const allCases = readDataset(path.resolve(args.dataset), splits)
@@ -621,28 +676,54 @@ async function main() {
621
676
  return false
622
677
  })
623
678
 
624
- // 增量回归:只重跑指定上轮的失败案例(修复验证快路径);
625
- // 全量防回归仍用不带该开关的整轮
626
- let failedSet = null
627
- if (args.onlyFailed) {
628
- const prevReportsDir = path.join(path.resolve(args.onlyFailed), 'reports')
629
- let failedIds = []
679
+ // 选样模式(互斥):--only 手点案例 / --only-failed 上轮失败例 / --focus 上轮焦点集。
680
+ // 焦点集 = 失败例 + 「通过但低于 --focus-below」的刚过线样本,按还原度升序,对应 SKILL
681
+ // 「定向还原度迭代协议」步骤 1;此前要人工翻 summary.md 抄 caseId,低分通过例常被漏掉。
682
+ const modes = ['only', 'onlyFailed', 'focus'].filter((key) => args[key])
683
+ if (modes.length > 1) throw new Error(`--only / --only-failed / --focus 互斥(同时传入:${modes.join(', ')})`)
684
+ let selectedSet = null
685
+ if (args.onlyFailed || args.focus) {
686
+ const prevRun = String(args.onlyFailed || args.focus)
687
+ let prevReports
630
688
  try {
631
- failedIds = fs.readdirSync(prevReportsDir)
632
- .filter((f) => f.endsWith('.json') && !/skipped|review|semantic|summary/.test(f))
633
- .map((f) => JSON.parse(fs.readFileSync(path.join(prevReportsDir, f), 'utf8')))
634
- .filter((r) => !r.passed)
635
- .map((r) => r.caseId)
636
- } catch (e) { throw new Error('--only-failed: cannot read ' + prevReportsDir + ': ' + e.message) }
637
- failedSet = new Set(failedIds)
638
- console.log(`[only-failed] ${path.basename(args.onlyFailed)} 失败 ${failedIds.length} 例,本轮增量重跑`)
689
+ prevReports = loadRunReports(prevRun)
690
+ } catch (error) {
691
+ throw new Error(`${args.onlyFailed ? '--only-failed' : '--focus'}: 读不到 ${prevRun}: ${error.message}`)
692
+ }
693
+ if (args.onlyFailed) {
694
+ selectedSet = new Set([...prevReports.values()].filter((report) => !report.passed).map((report) => report.caseId))
695
+ console.log(`[only-failed] ${path.basename(prevRun)} 失败 ${selectedSet.size} 例,本轮增量重跑`)
696
+ } else {
697
+ const threshold = Number(args.focusBelow) || 0.85
698
+ const cap = Number(args.focusLimit) || 0
699
+ const ranked = [...prevReports.values()]
700
+ .map((report) => ({
701
+ caseId: report.caseId,
702
+ fidelity: typeof report.fidelity?.overall === 'number' ? report.fidelity.overall : 0,
703
+ passed: !!report.passed,
704
+ }))
705
+ .filter((item) => !item.passed || item.fidelity < threshold)
706
+ .sort((a, b) => a.fidelity - b.fidelity || String(a.caseId).localeCompare(String(b.caseId)))
707
+ const picked = cap > 0 ? ranked.slice(0, cap) : ranked
708
+ selectedSet = new Set(picked.map((item) => item.caseId))
709
+ console.log(`[focus] ${path.basename(prevRun)} 焦点集 ${selectedSet.size} 例(未通过或 fidelity<${threshold}${cap > 0 ? `,上限 ${cap}` : ''})`)
710
+ if (picked.length) console.log(`[focus] 等价命令:--only ${picked.map((item) => item.caseId).join(',')}`)
711
+ }
712
+ } else if (args.only) {
713
+ selectedSet = new Set(String(args.only).split(',').filter(Boolean))
714
+ }
715
+
716
+ // --resume:已有报告的案例(含 --inherit 搬进来的)不再重跑,只补缺口
717
+ let doneSet = null
718
+ if (args.resume) {
719
+ try { doneSet = new Set(loadRunReports(runDir).keys()) } catch { doneSet = new Set() }
720
+ if (doneSet.size) console.log(`[resume] 跳过已完成 ${doneSet.size} 例`)
639
721
  }
640
722
 
641
723
  const cases = casePool.filter((testCase) => {
642
- if (failedSet) return failedSet.has(testCase.caseId)
643
- if (!args.only) return true
644
- const selected = new Set(String(args.only).split(',').filter(Boolean))
645
- return selected.has(testCase.caseId)
724
+ if (selectedSet && !selectedSet.has(testCase.caseId)) return false
725
+ if (doneSet && doneSet.has(testCase.caseId)) return false
726
+ return true
646
727
  }).slice(0, Number(args.limit) || undefined)
647
728
  const skippedPopups = allCases.filter((testCase) =>
648
729
  (testCase.tags || []).some((tag) => popupTypes.has(String(tag).toLowerCase())))
@@ -657,14 +738,59 @@ async function main() {
657
738
  })
658
739
  const reviewQueue = []
659
740
  const semanticReviewQueue = []
741
+ // --inherit:把上轮「本轮不重跑」的案例报告与截图搬进本轮,让增量轮成为完整可比轮。
742
+ // 否则 score_round 的 passRate 分母只剩失败例、round_diff 把未重跑案例全算成 removed,
743
+ // 快路径轮的指标读起来像整轮崩了。cards/slices 仍只含本轮重跑案例,
744
+ // 所以继承轮可用于门禁与回归对账,但不作冻结锚点载体(锚点必须整轮全量跑)。
745
+ const inheritedCases = []
746
+ if (args.inherit) {
747
+ const inheritFrom = path.resolve(String(args.inherit))
748
+ const inheritReportsDir = path.join(inheritFrom, 'reports')
749
+ let inheritReports
750
+ try {
751
+ inheritReports = loadRunReports(inheritFrom)
752
+ } catch (error) {
753
+ throw new Error(`--inherit: 读不到 ${inheritFrom}: ${error.message}`)
754
+ }
755
+ const rerunIds = new Set(cases.map((testCase) => testCase.caseId))
756
+ for (const [caseId, report] of inheritReports) {
757
+ if (rerunIds.has(caseId)) continue
758
+ if (fs.existsSync(path.join(reportDir, `${caseId}.json`))) continue
759
+ for (const suffix of ['.json', '.render.png', '.design.png']) {
760
+ const source = path.join(inheritReportsDir, `${caseId}${suffix}`)
761
+ if (fs.existsSync(source)) fs.copyFileSync(source, path.join(reportDir, `${caseId}${suffix}`))
762
+ }
763
+ inheritedCases.push(caseId)
764
+ if (!report.passed) {
765
+ reviewQueue.push({
766
+ caseId,
767
+ priority: report.validate?.errors ? 1 : 2,
768
+ reason: report.failureSignature || (report.error ? `case-error:${errorKind(report.error)}` : 'metric-gate'),
769
+ failureClass: report.failureClass || null,
770
+ round: args.round,
771
+ inherited: true,
772
+ })
773
+ }
774
+ }
775
+ fs.writeFileSync(path.join(reportDir, 'inherited.json'),
776
+ JSON.stringify({ from: inheritFrom, count: inheritedCases.length, caseIds: inheritedCases }, null, 2) + '\n')
777
+ console.log(`[inherit] 从 ${path.basename(inheritFrom)} 继承 ${inheritedCases.length} 例,本轮重跑 ${cases.length} 例`)
778
+ }
660
779
  const sourceBefore = sourceChanges()
661
780
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
662
781
  // 共享渲染会话:整轮复用一个 Chrome + HTTP server。
663
782
  // 此前每卡各启一套(renderCardScreenshot 内部建/拆会话),~1.5-2s/卡全是启停开销。
664
783
  // 冷帧(会话首帧缺背景图)根因已由 render_card.mjs 的 CDP 网络静默等待闭环,整轮无需暖机帧。
665
- let renderSession = await createRenderSession({ cardsRoot: cardsDir })
784
+ // 渲染会话懒启动:--resume 续跑「已全部完成」的轮次、或焦点集为空时,
785
+ // 不必白起一套 Chrome + HTTP server(本机实测 ~2-14s 启停开销)
786
+ if (!cases.length) console.log('[skip] 本轮无待跑案例(选样为空或已全部完成)')
787
+ let renderSession = null
788
+ const ensureRenderSession = async () => {
789
+ if (!renderSession) renderSession = await createRenderSession({ cardsRoot: cardsDir })
790
+ return renderSession
791
+ }
666
792
  const renderOnce = async (id, w, h) => {
667
- let res = await renderSession.render({
793
+ let res = await (await ensureRenderSession()).render({
668
794
  id, width: w, captureHeight: h, format: 'png', captureElements: true,
669
795
  })
670
796
  if (!res.ok && /session-closed|Target|WebSocket|Chrome|浏览器|超时/i.test(String(res.error || '') + String(res.message || ''))) {
@@ -716,6 +842,7 @@ async function main() {
716
842
  }
717
843
  }
718
844
  const exclusionRects = (designCard.elements || [])
845
+ const fontRects = []
719
846
  .filter((element) => ['missing-slice', 'background-slice-unavailable']
720
847
  .includes(element.annotationDefect?.type))
721
848
  .filter((element) => {
@@ -731,17 +858,23 @@ async function main() {
731
858
  for (const element of designCard.elements || []) {
732
859
  if (element.type !== 'text') continue
733
860
  const family = String(element.textStyle?.fontFamily || element.textStyle?.fontFace || '')
734
- if (family && !/^PingFang/i.test(family)) {
861
+ // 环境感知掩膜门:探针实测「回退栈宽/标注宽」>1.06 即该字体在本机渲染环境不可得
862
+ // (PingFang SC 在 Windows 同样回退合成,家族名豁免会让最大像素热带漏掩);
863
+ // 探针缺失时回退家族名判据。掩膜只豁免字形度量差,位置/结构/颜色仍覆盖这些文本。
864
+ const widen = fontMaskWiden(element)
865
+ const familyUnsupported = family && !/^PingFang/i.test(family)
866
+ const envUnsupported = !!loadFontMetrics() && widen > 1.06
867
+ if (familyUnsupported || envUnsupported) {
735
868
  // 掩膜按标注对齐方式锚定扩宽:居中窄体文本的渲染字形向两侧溢出设计层框,
736
869
  // 只向右扩会漏掩左半溢出带(实测单带热区可达 66%)。
737
870
  const base = toExclRect(element, 1)
738
- const widened = toExclRect(element, fontMaskWiden(element))
871
+ const widened = toExclRect(element, widen)
739
872
  if (String(element.textStyle?.textAlign || 'left') === 'center') {
740
873
  const shift = (widened.width - base.width) / 2
741
874
  widened.x = Math.max(0, base.x - shift)
742
875
  widened.width = Math.min(1 - widened.x, widened.width + shift)
743
876
  }
744
- exclusionRects.push({ ...widened, reason: 'unsupported-font' })
877
+ fontRects.push({ ...widened, reason: 'unsupported-font' })
745
878
  }
746
879
  }
747
880
  const renderWidth = Math.round(designCard?.rect?.width || spec.size?.width || 375)
@@ -895,12 +1028,38 @@ async function main() {
895
1028
  fs.writeFileSync(renderPath, Buffer.from(rendered.base64, 'base64'))
896
1029
  // 同步保存设计稿参考图到 reports 目录,便于 summary.md 并排对比
897
1030
  try { fs.copyFileSync(testCase.input.designImage, path.join(reportDir, testCase.caseId + '.design.png')) } catch {}
1031
+ // ── 裁切兜底切图掩膜:导出素材缺失 → 只能从画板裁切(__artboard_crop__*)──
1032
+ // 裁切块自带周边底色/圆角残边/邻件像素,与「导出素材」的渲染差异属素材来源差距,
1033
+ // 不是生成缺陷 → 按设计稿标注盒精确掩膜(外扩 1.5px 吸收抗锯齿边),
1034
+ // 仅掩「生成 schema 真实引用了该裁切文件」的元素(未引用=生成端已另行还原,差异仍计分)。
1035
+ {
1036
+ const schemaJson = JSON.stringify(generated.schema)
1037
+ for (const element of designCard.elements || []) {
1038
+ const defect = element.annotationDefect
1039
+ const cropFile = defect?.cropSlice || (defect?.resolution === 'artboard-crop' ? defect?.name : null)
1040
+ if (!defect || defect.type !== 'missing-slice' || !cropFile) continue
1041
+ if (!schemaJson.includes(cropFile)) continue
1042
+ const pad = 1.5
1043
+ const base = toExclRect(element, 1)
1044
+ exclusionRects.push({
1045
+ x: Math.max(0, base.x - pad / _exclCardW),
1046
+ y: Math.max(0, base.y - pad / _exclCardH),
1047
+ width: Math.min(1, base.width + (pad * 2) / _exclCardW),
1048
+ height: Math.min(1, base.height + (pad * 2) / _exclCardH),
1049
+ reason: 'crop-fallback-slice',
1050
+ })
1051
+ }
1052
+ }
898
1053
  report.pixel = imageFidelity(
899
1054
  fs.readFileSync(testCase.input.designImage),
900
1055
  fs.readFileSync(renderPath),
901
1056
  exclusionRects,
1057
+ fontRects,
902
1058
  )
903
- report.pixelExclusions = exclusionRects
1059
+ report.pixelExclusions = [...exclusionRects, ...fontRects]
1060
+ // 掩膜覆盖率:掩膜是「不计分」而不是「还原对」。覆盖率过高时通过结论不可信,
1061
+ // score_round 会把高掩膜案例单列出来要求人工复核(防止靠掩膜虚高过门)。
1062
+ report.exclusionCoverage = exclusionCoverage([...exclusionRects, ...fontRects])
904
1063
  report.textRecall = textRecall(generated.schema, testCase.expected?.requiredTexts)
905
1064
  // ── Element-level position scoring ──
906
1065
  const cardRect = designCard?.rect || {}
@@ -970,33 +1129,38 @@ async function main() {
970
1129
  report.position,
971
1130
  report.textOverflow,
972
1131
  )
973
- report.passed =
974
- report.validate.errors === 0 &&
975
- report.lint.errors === 0 &&
976
- report.pixel.ratio <= 0.08 &&
977
- report.fidelity.overall >= 0.85 &&
978
- report.fidelity.components >= 0.75 &&
979
- report.fidelity.position >= 0.80 &&
980
- report.fidelity.textOverflow >= 0.90 &&
981
- (report.duplication?.overall ?? 1) >= 0.95 &&
982
- report.pixel.dimension >= 0.95 &&
983
- report.pixel.color >= 0.90 &&
984
- report.pixel.structure >= 0.85
1132
+ // 单案例门以 lib/scoring.mjs 的 PASS_GATE 为唯一来源(SKILL.md 验收表、score_round 同源,
1133
+ // 文档数字由 tools/tests/scoring.test.mjs 反向断言),并把未达标维度按缺口落盘供聚类归因
1134
+ const gate = evaluatePassGate(report)
1135
+ report.gateFailures = gate.failures
1136
+ report.passed = gate.passed
1137
+ report.failureSignature = failureSignature(report)
1138
+ const attribution = attributeFailure(report)
1139
+ if (attribution) {
1140
+ report.failureClass = attribution.failureClass
1141
+ report.failureReason = attribution.reason
1142
+ }
985
1143
  } catch (error) {
986
1144
  report.error = String(error.message || error)
987
1145
  report.passed = false
1146
+ report.failureSignature = failureSignature(report)
1147
+ const attribution = attributeFailure(report)
1148
+ if (attribution) {
1149
+ report.failureClass = attribution.failureClass
1150
+ report.failureReason = attribution.reason
1151
+ }
988
1152
  }
989
1153
  report.durationMs = Date.now() - started
990
1154
  fs.writeFileSync(path.join(reportDir, `${testCase.caseId}.json`),
991
1155
  JSON.stringify(report, null, 2) + '\n')
992
1156
  if (!report.passed) reviewQueue.push({
993
1157
  caseId: testCase.caseId, priority: report.validate.errors ? 1 : 2,
994
- reason: report.error ||
995
- (report.upstream && !report.upstream.passed
996
- ? `parser:${report.upstream.annotationCompleteness?.toFixed(3) || 'N/A'}`
997
- : (report.duplication?.overall ?? 1) < 0.95
998
- ? `duplicate-content:${report.duplication.excessCount}`
999
- : `fidelity:${report.fidelity?.overall?.toFixed(3) || 'N/A'}`),
1158
+ // reason 用主失败签名(归一化缺口最大的维度),并带失败类与全部未达标维度,
1159
+ // 复核队列因此可直接聚类,不必逐例打开报告
1160
+ reason: report.error || report.failureSignature || 'metric-gate',
1161
+ failureClass: report.failureClass || null,
1162
+ gateFailures: (report.gateFailures || []).map((item) => item.key),
1163
+ fidelity: report.fidelity?.overall ?? null,
1000
1164
  round: args.round,
1001
1165
  })
1002
1166
  if (!report.passed && report.pixel?.comparable) semanticReviewQueue.push({
@@ -1023,7 +1187,7 @@ async function main() {
1023
1187
  }
1024
1188
  } finally {
1025
1189
  // 任何异常路径都要关掉 Chrome + HTTP server,杜绝进程残留
1026
- try { await renderSession.close() } catch {}
1190
+ try { await renderSession?.close() } catch {}
1027
1191
  }
1028
1192
  fs.writeFileSync(path.join(reportDir, 'review-queue.jsonl'),
1029
1193
  reviewQueue.map((item) => JSON.stringify(item)).join('\n') + (reviewQueue.length ? '\n' : ''))
@@ -1031,6 +1195,17 @@ async function main() {
1031
1195
  semanticReviewQueue.map((item) => JSON.stringify(item)).join('\n') +
1032
1196
  (semanticReviewQueue.length ? '\n' : ''))
1033
1197
 
1198
+ // 一轮跑完直接出门禁汇总:此前还要人工再敲一次 score_round,忘了就没有 summary.json / roundGate
1199
+ if (!args.noSummary) {
1200
+ const scoreScript = path.join(import.meta.dirname, 'score_round.mjs')
1201
+ const scored = spawnSync(process.execPath, [scoreScript, '--run', runDir, '--quiet'], {
1202
+ encoding: 'utf8', cwd: repoRoot,
1203
+ })
1204
+ const output = `${scored.stdout || ''}${scored.stderr || ''}`.trim()
1205
+ if (output) console.log(output)
1206
+ if (scored.status !== 0) console.warn(`[WARN] score_round 退出码 ${scored.status}(summary 可能未写出)`)
1207
+ }
1208
+
1034
1209
  const newSourceChanges = sourceChanges().filter((line) => !sourceBefore.includes(line))
1035
1210
  if (newSourceChanges.length) {
1036
1211
  throw new Error(`SANDBOX_VIOLATION: training wrote into src/: ${newSourceChanges.join(', ')}`)