cardbuddy 0.14.2 → 0.15.0

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 (68) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
  3. package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/mcp/client.mjs +8 -1
  7. package/dist/vendored/server/core/ai-chat.js +1521 -124
  8. package/dist/vendored/server/core/browser.js +12 -3
  9. package/dist/vendored/server/core/cardFiles.js +3 -0
  10. package/dist/vendored/server/core/cardHistory.js +1 -0
  11. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  12. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  13. package/dist/vendored/server/core/cardSave.js +1 -0
  14. package/dist/vendored/server/core/cardSync.js +3 -0
  15. package/dist/vendored/server/core/codeBridge.js +5 -1
  16. package/dist/vendored/server/core/logStream.js +1 -0
  17. package/dist/vendored/server/core/sliceStatic.js +24 -4
  18. package/dist/vendored/server/core/sliceUpload.js +1 -0
  19. package/dist/vendored/server/core/turnJournal.js +6 -0
  20. package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
  21. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  22. package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
  23. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
  24. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
  25. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
  26. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  27. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
  28. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  29. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
  30. package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
  31. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  33. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
  34. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  36. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
  37. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
  38. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
  41. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
  42. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  43. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
  46. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  49. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  50. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
  51. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
  52. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
  53. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
  54. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
  55. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  56. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
  57. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
  58. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  59. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
  60. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
  61. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
  62. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
  63. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
  64. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
  65. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
  66. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
  67. package/package.json +1 -1
  68. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
@@ -36,10 +36,13 @@ import path from 'node:path'
36
36
  import readline from 'node:readline'
37
37
  import crypto from 'node:crypto'
38
38
  import { spawnSync } from 'node:child_process'
39
+ import { fileURLToPath } from 'node:url'
39
40
 
40
41
  import { expandIR, runAutofixAndValidate, syncReferencedSlices } from './expand_ir.mjs'
41
42
  import { rewriteSliceRefs } from './lib/slice_dedupe.mjs'
43
+ import { auditGeometry, describeGeometry } from './lib/geometry_audit.mjs'
42
44
  import { lint } from './lint_card.mjs'
45
+ import { buildFidelityReport } from './check_fidelity.mjs'
43
46
  import { createRenderSession, renderCardScreenshot } from './render_card.mjs'
44
47
  import { copyCardDesignImage } from './lib/design_image.mjs'
45
48
  import { cardsDir, cardSchemaPath, cardDataPath, readJson, repoRoot } from './lib/paths.mjs'
@@ -227,6 +230,210 @@ function diffHotspots(renderPng, designPng, { cols = 4, rows = 8, floorPct = 3,
227
230
  return cells.slice(0, maxCells)
228
231
  }
229
232
 
233
+ /* ── 分项归因(contributors):把差异热区落到具体渲染节点 ─────────
234
+ * 存在理由(2026-09-21 实测):那一轮 23 次 iterate 的报告只说「match 90.04、高度差 2px」,
235
+ * 从不说是**哪个节点**差 —— 模型只能整份重写 IR 去试,16 次全量重写、12.4 万字符、约占全部输出的一半。
236
+ * 能力其实齐了(render_card.mjs 的 elementBoxes 逐元素度量、iterate 的 --elements),
237
+ * 缺的只是「热区 → 元素」这一步映射。
238
+ *
239
+ * 坐标系:hotspots 已由 diffHotspots 折算到 1x 卡内坐标,elementBoxes 本就是 .phone__screen
240
+ * 相对 CSS 像素 —— 两者同系,可直接相交。spec.json 的元素是**画板坐标**(1x,如 375 画板),
241
+ * 需减去 card.rect 的 (x,y) 平移到卡内。
242
+ *
243
+ * 归因原则:只给**可证伪的事实**(元素自己实测的 h / scrollH、同类中位高度、设计稿高度),
244
+ * 不猜「疑似哪个属性」以外的因果;宁缺勿滥 —— 匹配不上的元素就不给 designH。
245
+ */
246
+ const CONTRIB_MAX_HOTSPOTS = 5
247
+ const CONTRIB_MAX_ELEMENTS = 3
248
+ /** 元素主体落在热区内的最小比例:低于此值说明只是擦边,不算这个热区的成因 */
249
+ const CONTRIB_SELF_RATIO = 0.5
250
+ /** 与设计稿文本匹配时允许的最大纵向位移(卡内 1x px):超过就当同名不同节点,不给 deltaH */
251
+ const CONTRIB_MATCH_DY = 24
252
+
253
+ const r1 = (n) => Math.round(n * 10) / 10
254
+ const normText = (t) => String(t ?? '').replace(/\s+/g, ' ').trim()
255
+
256
+ /**
257
+ * 读设计稿侧的卡内元素(供 deltaH)。
258
+ * 卡索引按 `--design` 的 `card-N.png` 推断(多卡画板的聚焦裁切命名约定),缺省第一张。
259
+ */
260
+ /**
261
+ * 跑一次要素清单比对(spec 文本 → schema 节点),失败不阻断本轮(宁缺勿滥:拿不到就不给门禁项)。
262
+ * 卡片序号与 loadDesignElements 同源(设计稿文件名 card-N.png),保证「比的是同一张卡」。
263
+ */
264
+ export function loadFidelityReport({ specPath, schemaPath, designPath, cardId }) {
265
+ if (!specPath || !schemaPath || !fs.existsSync(specPath) || !fs.existsSync(schemaPath)) return null
266
+ try {
267
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'))
268
+ const cards = Array.isArray(spec.cards) ? spec.cards : []
269
+ const m = /card-(\d+)\.png$/i.exec(String(designPath || ''))
270
+ const idx = m ? Math.max(0, Math.min(cards.length - 1, Number(m[1]) - 1)) : 0
271
+ const r = buildFidelityReport({ spec: specPath, schema: schemaPath, card: idx + 1 })
272
+ return {
273
+ cardId,
274
+ total: r.elements.texts.total,
275
+ matched: r.elements.texts.matched,
276
+ missing: r.elements.texts.missing,
277
+ invented: r.elements.texts.invented,
278
+ slices: r.elements.slices,
279
+ }
280
+ } catch {
281
+ return null
282
+ }
283
+ }
284
+
285
+ export function loadDesignElements(specPath, designPath) {
286
+ if (!specPath || !fs.existsSync(specPath)) return null
287
+ let spec
288
+ try {
289
+ spec = JSON.parse(fs.readFileSync(specPath, 'utf8'))
290
+ } catch {
291
+ return null
292
+ }
293
+ const cards = Array.isArray(spec.cards) ? spec.cards : []
294
+ if (!cards.length) return null
295
+ const m = /card-(\d+)\.png$/i.exec(String(designPath || ''))
296
+ const idx = m ? Math.max(0, Math.min(cards.length - 1, Number(m[1]) - 1)) : 0
297
+ const card = cards[idx]
298
+ const cr = card?.rect
299
+ if (!cr) return null
300
+ const src = Array.isArray(card.elements) && card.elements.length ? card.elements : Array.isArray(spec.elements) ? spec.elements : []
301
+ const tx = Number(cr.x) || 0
302
+ const ty = Number(cr.y) || 0
303
+ return {
304
+ cardIndex: idx,
305
+ width: Number(cr.width) || 0,
306
+ height: Number(cr.height) || 0,
307
+ /** y 已平移到卡内坐标,与渲染元素同系 */
308
+ texts: src
309
+ .filter((e) => e?.rect && normText(e.text))
310
+ .map((e) => ({ text: normText(e.text), h: Number(e.rect.height) || 0, y: (Number(e.rect.y) || 0) - ty })),
311
+ }
312
+ }
313
+
314
+ /** 分项归因:热区 → 覆盖率最高的最内层元素(+ 可选的设计稿高度差) */
315
+ export function attributeContributors({ hotspots, elements, design, contentOffset1x }) {
316
+ if (!Array.isArray(hotspots) || !hotspots.length) return null
317
+ const els = (elements || []).filter((e) => e && e.w > 0 && e.h > 0)
318
+ if (!els.length) return null
319
+
320
+ // 同类元素高度中位数:抓「同类里某一项高度异常」。整体偏移/缩放会同时影响同类,
321
+ // 所以这个判据不会把「整卡都差 2px」误报成「某个元素有问题」。
322
+ const hByCls = new Map()
323
+ for (const e of els) {
324
+ const k = e.cls || '?'
325
+ if (!hByCls.has(k)) hByCls.set(k, [])
326
+ hByCls.get(k).push(e.h)
327
+ }
328
+ const hMed = new Map()
329
+ for (const [k, arr] of hByCls) {
330
+ const s = [...arr].sort((a, b) => a - b)
331
+ hMed.set(k, s[Math.floor(s.length / 2)])
332
+ }
333
+
334
+ // 设计稿文本 → 高度(同文本多条时取 y 最近的一条,且位移要在容差内)
335
+ const designTexts = design?.texts || []
336
+ const pick = (e) => {
337
+ const t = normText(e.text)
338
+ if (!t) return null
339
+ let hit = null
340
+ for (const d of designTexts) {
341
+ if (d.text !== t) continue
342
+ const dy = Math.abs(d.y - e.y)
343
+ if (dy > CONTRIB_MATCH_DY) continue
344
+ if (!hit || dy < hit.dy) hit = { d, dy }
345
+ }
346
+ return hit
347
+ }
348
+
349
+ const out = []
350
+ for (const hot of hotspots.slice(0, CONTRIB_MAX_HOTSPOTS)) {
351
+ const area = Math.max(1, hot.w * hot.h)
352
+ const inHot = []
353
+ for (const e of els) {
354
+ const ox = Math.max(0, Math.min(e.x + e.w, hot.x + hot.w) - Math.max(e.x, hot.x))
355
+ const oy = Math.max(0, Math.min(e.y + e.h, hot.y + hot.h) - Math.max(e.y, hot.y))
356
+ const inter = ox * oy
357
+ if (inter <= 0) continue
358
+ const selfRatio = inter / (e.w * e.h)
359
+ if (selfRatio < CONTRIB_SELF_RATIO) continue // 主体不在此热区,只是擦边
360
+ inHot.push({ e, inter })
361
+ }
362
+ if (!inHot.length) continue
363
+ // 面积升序 = 最具体的节点在前:热区里嵌套的父/子都命中时,最小的那个才是差异所在
364
+ inHot.sort((a, b) => a.e.w * a.e.h - b.e.w * b.e.h)
365
+
366
+ const picked = inHot.slice(0, CONTRIB_MAX_ELEMENTS).map(({ e, inter }) => {
367
+ const m = pick(e)
368
+ const dH = m ? r1(e.h - m.d.h) : null
369
+ const med = hMed.get(e.cls) ?? null
370
+ const flags = []
371
+ if (e.scrollH > e.clientH + 1) flags.push('text-clipped')
372
+ if (med != null && Math.abs(e.h - med) >= 1) flags.push(`vs-siblings ${r1(e.h - med) > 0 ? '+' : ''}${r1(e.h - med)}`)
373
+ if (dH != null && Math.abs(dH) >= 1) flags.push(`vs-design ${dH > 0 ? '+' : ''}${dH}`)
374
+ if (Math.abs(hot.y + hot.h - (e.y + e.h)) <= 2) flags.push('bottom-edge')
375
+ if (Math.abs(hot.x + hot.w - (e.x + e.w)) <= 2) flags.push('right-edge')
376
+ return {
377
+ cid: e.cid,
378
+ cls: e.cls,
379
+ text: String(e.text || '').slice(0, 24),
380
+ rect: [e.x, e.y, e.w, e.h],
381
+ h: e.h,
382
+ hSibling: med,
383
+ designH: m ? m.d.h : null,
384
+ dH,
385
+ hotCover: Math.round((inter / area) * 100) / 100,
386
+ flags,
387
+ }
388
+ })
389
+
390
+ const top = picked[0]
391
+ const acts = []
392
+ if (top.flags.includes('text-clipped')) acts.push('该节点文本被裁剪(scrollH > clientH)⇒ 查 isMaxLine / maxLine / lineHeight')
393
+ if (top.flags.some((f) => f.startsWith('vs-design'))) acts.push(`该节点高度与设计稿差 ${top.dH} ⇒ 查 paddingTop/Bottom 与 lineHeight`)
394
+ if (top.flags.some((f) => f.startsWith('vs-siblings'))) acts.push(`该节点高度与同类中位(${top.hSibling})不同 ⇒ 查该节点的 padding/margin,而不是整层 gap`)
395
+ if (top.flags.includes('bottom-edge') || top.flags.includes('right-edge')) acts.push('热区贴该节点下/右边界 ⇒ 差异在边界外(下边距 / 圆角 / 阴影)或该节点比设计高')
396
+ if (!acts.length) acts.push('按热区内的具体节点逐个核对 padding / lineHeight / gap')
397
+ out.push({
398
+ hotspot: [hot.x, hot.y, hot.w, hot.h],
399
+ diffPct: hot.diffPct,
400
+ elements: picked,
401
+ hint: `热区 [${hot.x},${hot.y},${hot.w},${hot.h}](差异 ${hot.diffPct}%)主要落在 ${top.cls || '?'}「${top.text || '无文本'}」(${top.cid}) 上:${acts[0]}。一轮只改这一项,改完立刻重跑 iterate 看是否刷新最好成绩${contentOffset1x && (Math.abs(contentOffset1x.dx) > 2 || Math.abs(contentOffset1x.dy) > 2) ? ';注意本轮内容整体偏移 dx=' + contentOffset1x.dx + ' dy=' + contentOffset1x.dy + ',先修对齐再动这里' : ''}。`,
402
+ })
403
+ }
404
+ return out.length ? out : null
405
+ }
406
+
407
+ /** 清除多余轮次快照:保留最近 keep 轮 + best(每次 iterate 收尾调用,避免无限堆积) */
408
+ function pruneSnapshots(cardId, keep = 12) {
409
+ try {
410
+ const names = fs
411
+ .readdirSync(SCRATCH_DIR)
412
+ .filter((n) => n.startsWith(`${cardId}.ir.r`) && n.endsWith('.json'))
413
+ if (names.length <= keep) return 0
414
+ let bestRound = 0
415
+ try {
416
+ bestRound = Number(JSON.parse(fs.readFileSync(path.join(SCRATCH_DIR, `${cardId}.iterate.json`), 'utf8'))?.best?.round) || 0
417
+ } catch {}
418
+ const roundOf = (n) => Number(/(\d+)\.json$/.exec(n)?.[1] || 0)
419
+ // 先按轮次降序,保留最近 keep 轮;best 那一轮额外豁免(回退要按 --round 直取文件名)
420
+ const doomed = names
421
+ .sort((a, b) => roundOf(b) - roundOf(a))
422
+ .slice(keep)
423
+ .filter((n) => roundOf(n) !== bestRound)
424
+ let n = 0
425
+ for (const f of doomed) {
426
+ try {
427
+ fs.unlinkSync(path.join(SCRATCH_DIR, f))
428
+ n++
429
+ } catch {}
430
+ }
431
+ return n
432
+ } catch {
433
+ return 0
434
+ }
435
+ }
436
+
230
437
  /* ── 合成并排对比图(内存,可选写盘) ──────────────────────── */
231
438
  function composeSideBySide(designBuf, renderBuf) {
232
439
  const designPng = PNG.sync.read(designBuf)
@@ -291,6 +498,72 @@ function hashFile(p) {
291
498
  }
292
499
  }
293
500
 
501
+ /**
502
+ * 停滞判据(2026-09-21 重写):以**历史最好成绩**为基准,看连续多少轮没有刷新它。
503
+ *
504
+ * 旧判据要求「match 与上轮几乎相同(|Δ|<0.3)**且** top-3 热区坐标全等」才累加,而实测形态是
505
+ * **震荡下滑**(79.6→92.42→90.04,13 轮里 11 轮更差)——热区每轮都在动,23 份报告里 stalled
506
+ * 出现 0 次,模型却按提示词在等这个永不出现的信号,一路盲调到 707 万 token 才被人工中止。
507
+ * 「没有更好」比「一模一样」才是真正该叫停的形态。
508
+ */
509
+ const STALL_ROUNDS = 3
510
+ /**
511
+ * 高度差连续多少轮一模一样(且不为 0)即判「卡在同一处」。
512
+ * 门禁要求 heightDelta=0,而实测两次建卡回合都出现过「heightDelta 恒定 56、match 在原地打转」——
513
+ * 只盯 match 的判据会一直判「还在动」,模型就继续做像素微调,而那个撑高 56px 的结构问题从没被碰过。
514
+ * 高度一动不动 = 证据链该换了,不该再调像素。
515
+ */
516
+ const HEIGHT_FROZEN_ROUNDS = 3
517
+ /**
518
+ * 高度差得大到这个程度,冻结才算「结构问题」而不是「最后一轮才收的像素差」。
519
+ * 2px 这种量级可能是收尾一次性对齐掉的;56px 那种只可能是漏了/多了元素或间距累加。
520
+ * 没有这条下限,一条 match 从 79 一路涨到 92 的好轨迹会被误判成高度停滞(实测重放用例就是这种)。
521
+ */
522
+ const HEIGHT_FROZEN_MIN_PX = 8
523
+ /** match 提升多少才算刷新最好成绩(低于它是噪声,不算进步) */
524
+ const STALL_EPS = 0.5
525
+ /** 本轮比上轮低多少分算「明确变差」:连续变差时必须提示回退 */
526
+ const REGRESS_DELTA = 1
527
+ /**
528
+ * 每缺一条要素折算成多少「等效 match 分」。
529
+ *
530
+ * 判据三次演进(2026-09-21 二次修正,前两版都在真轨迹上翻车):
531
+ * ① 纯 match:把「删节点 / 换成自编占位文案换来 match 上升」记成进步,回退目标也存成那一轮。
532
+ * ② 字典序「要素齐优先于任何像素分」:修好了 ①,却在 helloCard 真轨迹上更糟——
533
+ * r1 要素齐(19/19)但 match 51.71 / heightDelta 800(结构整个塌掉、渲染高 888px),
534
+ * r4 只缺 2 条但 match 89.71 / heightDelta 20。字典序判 r1 最好,于是 r5 起一路「停滞」,
535
+ * 并把回退目标指向那个 51.71 的破烂结构 —— 模型越回退越差。
536
+ * ③ 折中(现行):`score = match − PENALTY × elementsMissing`,同量纲相加。
537
+ * 权重取 6(1 条要素 ≈ 6 分):
538
+ * · 缺 4 条换来 +12 分(92 vs 80)→ 不算进步,保住 ① 的原意(6×4=24 > 12);
539
+ * · 缺 2 条但多 38 分(89.71 vs 51.71)→ 算进步,不再把塌掉的结构当标杆(6×2=12 < 38)。
540
+ */
541
+ const MISSING_PENALTY = 6
542
+
543
+ /**
544
+ * 最好成绩的排序分:要素缺失折算成扣分,与 match 同量纲。
545
+ * 没有要素报告(非设计稿链路 / 未传 --spec)时就是裸 match。
546
+ */
547
+ function bestScore(h) {
548
+ if (!h || h.match == null) return null
549
+ const miss = Number.isFinite(h.elementsMissing) ? h.elementsMissing : null
550
+ return miss == null ? h.match : h.match - MISSING_PENALTY * miss
551
+ }
552
+
553
+ /**
554
+ * a 是否严格优于 b(带 STALL_EPS 噪声门槛)。
555
+ * 只在**两边都有要素报告**时才启用扣分口径;否则退化成纯 match 比较,
556
+ * 避免「有 fidelity 的轮次」与「无 fidelity 的轮次」混比时被凭空扣分。
557
+ */
558
+ function isBetterThan(a, b) {
559
+ if (!a || a.match == null) return false
560
+ if (!b || b.match == null) return true
561
+ const comparable = Number.isFinite(a.elementsMissing) && Number.isFinite(b.elementsMissing)
562
+ const sa = comparable ? bestScore(a) : a.match
563
+ const sb = comparable ? bestScore(b) : b.match
564
+ return sa > sb + STALL_EPS
565
+ }
566
+
294
567
  /** preflight.mjs = quickref「Pre-flight Checklist」12 条里可机械化的 10 条。
295
568
  * 此前它没被任何协议阶段点名,模型从来不知道它存在 —— 这里直接并进每轮迭代报告。 */
296
569
  function runPreflight({ cardId, specPath }) {
@@ -314,8 +587,42 @@ function runPreflight({ cardId, specPath }) {
314
587
  }
315
588
  }
316
589
 
590
+ /**
591
+ * 把「match 差多少分」翻译成「差在哪」。
592
+ *
593
+ * 实测(2026-09-21 建卡回合):报告只说「match 79.86、高度差 56」,从不说是哪个区域、
594
+ * 哪个节点差 —— 模型只能整份重写 IR 去试(另一轮实测 16 次全量重写、12.4 万字符,
595
+ * 约占全部输出的一半)。热区与 contributors 本来就都在报告里,缺的只是把结论说出口。
596
+ *
597
+ * 只转述报告里已有的事实,不新造推断:热区坐标来自 diffHotspots,节点来自 attributeContributors。
598
+ */
599
+ function describeMatchGap(r) {
600
+ const pd = r.pixelDiff || {}
601
+ const gap = Number((GATE.match - (pd.match ?? 0)).toFixed(2))
602
+ const parts = []
603
+ const hs = (pd.hotspots || []).slice(0, 3).map((h) => `(${h.x},${h.y}) ${h.w}×${h.h}`)
604
+ if (hs.length) parts.push(`最大差异区 ${hs.length} 处:${hs.join(' / ')}`)
605
+ const nodes = []
606
+ for (const c of (Array.isArray(r.contributors) ? r.contributors : []).slice(0, 2)) {
607
+ const el = (c.elements || [])[0]
608
+ if (!el) continue
609
+ const label = el.text ? `text="${String(el.text).slice(0, 18)}"` : el.cid || el.cls || '?'
610
+ const dH = el.dH != null ? ` 高差 ${el.dH > 0 ? '+' : ''}${el.dH}px` : ''
611
+ const flags = (el.flags || []).slice(0, 2).join(' ')
612
+ nodes.push(`${el.cls || '?'} ${label}${dH}${flags ? ` [${flags}]` : ''}`)
613
+ }
614
+ if (nodes.length) parts.push(`最可能的节点:${nodes.join(';')}`)
615
+ if (pd.size && (pd.size.heightDelta || pd.size.widthDelta)) {
616
+ parts.push(`尺寸差 height ${pd.size.heightDelta}px / width ${pd.size.widthDelta}px(门禁要求都为 0)`)
617
+ }
618
+ const co = r.contentOffset1x
619
+ if (co && (co.dx || co.dy)) parts.push(`整体内容偏移 ${co.dx}/${co.dy}px(门禁要求 |dx|,|dy|<=${GATE.contentOffset1x})`)
620
+ if (!parts.length) return null
621
+ return `还差 ${gap} 分。差在这些地方:${parts.join(';')}。一轮只改一处、且先改最内层那个节点,改完立刻重跑 iterate 拿新报告。`
622
+ }
623
+
317
624
  /** 门禁判定:全部数值项来自本轮报告,任何一项不达标 passed=false。 */
318
- function evaluateGate(r) {
625
+ export function evaluateGate(r) {
319
626
  const pd = r.pixelDiff || {}
320
627
  const co = r.contentOffset1x || null
321
628
  const checks = [
@@ -323,26 +630,145 @@ function evaluateGate(r) {
323
630
  { name: 'lint.errors', value: r.lint?.errors ?? null, required: '=0', ok: (r.lint?.errors ?? 1) === 0 },
324
631
  ]
325
632
  if (r.preflight) checks.push({ name: 'preflight.errors', value: r.preflight.errors, required: '=0', ok: r.preflight.errors === 0 })
633
+ // 要素线(内容真值):设计稿里的文案必须在 schema 里找得到。
634
+ // 放在像素检查之前 —— 要素缺失时先补要素,调像素是白费(改样式不会把丢掉的文案变回来)。
635
+ if (r.fidelity) {
636
+ checks.push({
637
+ name: 'elements.missing',
638
+ value: r.fidelity.missing.length,
639
+ required: '=0',
640
+ ok: r.fidelity.missing.length === 0,
641
+ })
642
+ }
326
643
  if (pd.match != null) checks.push({ name: 'pixelDiff.match', value: pd.match, required: `>=${GATE.match}`, ok: pd.match >= GATE.match })
644
+ // 几何隐形项:不做像素对比也能判死 —— 「被压成 0」「跑出卡片」用户必然看不见。
645
+ // 放在像素项之前(结构阶段),避免模型为了刷 match 去动已经正确的结构。
646
+ const geo = r.geometry
647
+ if (geo) {
648
+ checks.push({ name: 'geometry.collapsed', value: geo.counts?.collapsed ?? geo.collapsed?.length ?? 0, required: '=0', ok: (geo.counts?.collapsed ?? geo.collapsed?.length ?? 0) === 0 })
649
+ checks.push({ name: 'geometry.outOfCard', value: geo.counts?.outOfCard ?? geo.outOfCard?.length ?? 0, required: '=0', ok: (geo.counts?.outOfCard ?? geo.outOfCard?.length ?? 0) === 0 })
650
+ }
327
651
  if (pd.size) {
328
652
  checks.push({ name: 'pixelDiff.size.heightDelta', value: pd.size.heightDelta, required: `=${GATE.heightDelta}`, ok: pd.size.heightDelta === GATE.heightDelta })
329
653
  checks.push({ name: 'pixelDiff.size.widthDelta', value: pd.size.widthDelta, required: `=${GATE.widthDelta}`, ok: pd.size.widthDelta === GATE.widthDelta })
330
654
  }
331
655
  if (co) checks.push({ name: 'contentOffset1x', value: `${co.dx}/${co.dy}`, required: `|dx|,|dy|<=${GATE.contentOffset1x}`, ok: Math.abs(co.dx) <= GATE.contentOffset1x && Math.abs(co.dy) <= GATE.contentOffset1x })
332
656
  const failed = checks.filter((c) => !c.ok)
333
- return {
657
+ // 两段式门禁(骨架 → 样式):先让「要素齐全 + 结构合法」达标,再谈像素。
658
+ // 实测把两段混在一起时,模型会在「为满足 lint 改结构 → match 掉下来 → 改回去」之间震荡几十步。
659
+ const structureOk = !failed.some((c) => c.name !== 'pixelDiff.match' && !c.name.startsWith('pixelDiff') && c.name !== 'contentOffset1x')
660
+ const gate = {
334
661
  passed: failed.length === 0,
662
+ stage: failed.length === 0 ? 'pass' : structureOk ? 'pixel' : 'structure',
335
663
  checks,
336
664
  failed: failed.map((c) => `${c.name}=${c.value}(要求 ${c.required})`),
337
665
  verdict: failed.length === 0 ? 'PASS' : `FAIL:${failed.map((c) => `${c.name}=${c.value}`).join(',')}`,
338
666
  }
667
+ if (r.fidelity && r.fidelity.missing.length) {
668
+ gate.verdict +=
669
+ `;要素缺失 ${r.fidelity.missing.length} 条(设计稿有、schema 里找不到):${r.fidelity.missing.slice(0, 5).map((t) => `「${t}」`).join('、')}` +
670
+ ' —— **先补要素再调像素**:把丢掉的文案补回对应节点(或把绑定变量的 defaultValue 改成设计稿真值),补完重跑一轮再判门禁。' +
671
+ '注意别用占位文案顶替:schema 里出现设计稿没有的文案同样算要素错误。'
672
+ }
673
+ if (r.fidelity && r.fidelity.invented && r.fidelity.invented.length) {
674
+ gate.invented = r.fidelity.invented.slice(0, 10)
675
+ }
676
+ // 几何隐形项:把具体节点写进 verdict —— 只说「collapsed=2」等于让模型自己找是哪两个。
677
+ if (geo && (geo.collapsed?.length || geo.outOfCard?.length)) {
678
+ gate.geometry = { collapsed: geo.collapsed, outOfCard: geo.outOfCard, overWrapped: geo.overWrapped }
679
+ const detail = describeGeometry(geo)
680
+ const fix =
681
+ ' 修法:这些节点全在布局里,**别动视觉样式**——按 cid 找到那个节点,' +
682
+ '① 被压成 0 宽:说明同排有个容器把整行吃光了(横向流里的容器若不写 rect.width,渲染器按默认「100%」出),' +
683
+ '给那两者按设计稿补显式 rect.width(itemValueFrom:"0");确实要占满剩余空间就用 flexGrow:1 + flexShrink:1。' +
684
+ '② 跑出卡片右缘:同上,横向兄弟宽度之和超了卡片宽(卡内宽 = 卡宽 − 左右 padding),按设计稿重新分配宽度。'
685
+ gate.geometryDetail = detail
686
+ gate.verdict = `${gate.verdict};${detail}。${fix}`
687
+ }
688
+ // 像素类不达标时补一句「差在哪」:只报分数等于把「找哪里差」整段外包给模型的猜测试错。
689
+ if (failed.some((c) => c.name.startsWith('pixelDiff') || c.name === 'contentOffset1x')) {
690
+ const gapDetail = describeMatchGap(r)
691
+ if (gapDetail) {
692
+ gate.gapDetail = gapDetail
693
+ gate.verdict = `${gate.verdict};${gapDetail}`
694
+ }
695
+ }
696
+ // validate 没跑起来(机器上没有可用 python)⇒ 谁都修不好这一项:继续 --set 只是烧步数。
697
+ // 与普通 FAIL 区分开,调用方(模型)照 verdict 停手 + 告知用户,而不是接着盲调。
698
+ if (r.validate?.envError === true) {
699
+ gate.passed = false
700
+ gate.blockedBy = 'validate-no-python'
701
+ gate.envBlocked = 'validate 没跑起来:本机没有可用的 python 解释器'
702
+ gate.checks.push({ name: 'validate.runnable', value: 'no-python', required: '能用 python 跑 validate_schema.py', ok: false })
703
+ gate.verdict =
704
+ 'BLOCKED(环境问题,不是卡片的错):validate 没跑起来,改 schema / 继续 --set 都修不好它。' +
705
+ '停手,如实告诉用户「装 Python 或设 DSL_CARD_PYTHON=<解释器路径>」后重跑 iterate;' +
706
+ `其余检查:${failed.length ? failed.map((c) => `${c.name}=${c.value}`).join(',') : '全部达标'}`
707
+ }
708
+ return gate
339
709
  }
340
710
 
341
- /** 报告落盘 + 轮次历史 + 停滞检测(同热区连调多轮 = 微调已无收益,必须换证据链)。 */
342
- function persistReport(result, { sp, dp }) {
711
+ /**
712
+ * IR 轮次快照:每次 iterate 落一份 `<id>.ir.rNN.json`,刷新最好成绩时另存 `<id>.ir.best.json`。
713
+ * 以前没有任何历史版本 ⇒ 发现「越改越差」时唯一的办法是凭记忆全量重写整份 IR
714
+ * (实测 16 次 / 12.4 万字符 ≈ 全部输出的一半)。有快照,回退才是可执行的下一步。
715
+ */
716
+ function snapshotIR(cardId, irPath, round) {
717
+ if (!cardId || !irPath || !fs.existsSync(irPath)) return null
718
+ const name = `${cardId}.ir.r${String(round).padStart(2, '0')}.json`
719
+ try {
720
+ fs.copyFileSync(irPath, path.join(SCRATCH_DIR, name))
721
+ return { name, path: path.join(SCRATCH_DIR, name) }
722
+ } catch {
723
+ return null
724
+ }
725
+ }
726
+
727
+ /** 停滞提示:只给可执行的下一步(回退 → 单分项 → 换证据链),不再罗列一堆可选分析。 */
728
+ /** 高度差停滞提示:问题在「结构撑高」而不是「像素对齐」,别让它继续调像素。 */
729
+ function heightStalledBlock({ cardId, cur }) {
730
+ const hd = cur?.heightDelta
731
+ return [
732
+ `⚠ 高度差已连续 ${HEIGHT_FROZEN_ROUNDS} 轮一动不动(heightDelta=${hd},门禁要求 0):**问题在结构、不在像素**。`,
733
+ '再调 match 也过不了门禁——先把高度这 ${hd}px 定位出来(逐带 dy/dh 会指出是哪一段把总高撑出去的:通常是一处被漏掉/多出的元素,或某层 margin/padding 累加),只改那一处结构(补元素 / 删元素 / 改间距),改完立刻重跑 iterate 拿新的 heightDelta 做判据。',
734
+ ].join('\n')
735
+ }
736
+
737
+ function stalledHint({ cardId, roundsSinceBest, best, cur, last, regressed, gate, irRel, reason }) {
738
+ const failed = (gate?.failed || []).slice(0, 2).map((c) => `${c.name}=${c.value}(要求 ${c.required})`)
739
+ // 回退命令里的 `--ir` 必须是**本次跑的那份工作 IR**(persistReport 手里就有),不能猜路径:
740
+ // 模型把 IR 放哪儿由它自己决定(实测有 UI/_gen/<draft>/、.cache/ai-scratch/ 等多种落点),
741
+ // 猜错的结果是回退命令直接报「工作 IR 不存在」,白烧一步。
742
+ const irTarget = irRel || `.cache/ai-scratch/${cardId}.ir.json`
743
+ const lines = [
744
+ `已连续 ${roundsSinceBest} 轮没有刷新最好成绩:最好 ${best.match}% @ r${best.round},本轮 ${cur.match}%${regressed && last ? `(比上轮 ${last.match}% 还低)` : ''}。`,
745
+ ]
746
+ // 要素下滑单独点名:这类轮次**不该**继续调像素——像素分可能是靠删要素/换占位文案换来的。
747
+ if (cur.elementsMissing != null && best.elementsMissing != null && cur.elementsMissing > best.elementsMissing) {
748
+ lines.push(
749
+ `⚠ 本轮的要素比最好成绩还少 ${cur.elementsMissing - best.elementsMissing} 条(最好 r${best.round} 缺 ${best.elementsMissing} 条 / 本轮缺 ${cur.elementsMissing} 条):**这是在删要素凑像素**。先把要素补回来(设计稿有、schema 里没有的文案见报告 elements.missing / gate.verdict),再谈 match。`,
750
+ )
751
+ }
752
+ lines.push(
753
+ '--set 微调已到天花板,禁止继续盲调、禁止再全量重写 IR。按序做:',
754
+ `① 回退到最好结构:skill_exec script=skills/dsl-card-generator/scripts/ir_restore.mjs args=["--best",".cache/ai-scratch/${cardId}.ir.best.json","--ir","${irTarget}"] —— 回退后必须重跑一轮 iterate 拿新报告,不要拿旧报告当门禁。`,
755
+ failed.length
756
+ ? `② 只修一个分项:本轮 gate.failed 前两项是 ${failed.join(' / ')}。一轮只改一项,改完立刻重跑 iterate(同时改多项无法归因)。`
757
+ : '② 只修一个分项:按本轮 gate.failed 的第一项,一轮只改一项,改完立刻重跑 iterate。',
758
+ `③ 同一项连改两轮都不动 ⇒ 换证据链:skill_exec script=skills/dsl-card-generator/scripts/diff_bands.mjs args=["--id","${cardId}","--design","<设计稿>","--render","<本轮 renderImage>"] 拿逐带 dy/dh 与分区主色,再对照 references/generation_pitfalls.md 的「类型开关+值 / 静默丢弃」清单。`,
759
+ )
760
+ // 高度线单独触发时,先插一段「问题在结构、不在像素」的定向说明,再照常给回退与单分项建议
761
+ //(回退命令在两种停滞形态下都有用,不该因为换了判据就被吞掉)。
762
+ if (reason === 'height') lines.unshift(heightStalledBlock({ cardId, cur }))
763
+ return lines.join('\n')
764
+ }
765
+
766
+ /** 报告落盘 + 轮次历史 + 停滞检测(连续多轮没刷新最好成绩 = 微调到天花板,必须叫停并回退)。 */
767
+ export function persistReport(result, { sp, dp, irPath }) {
343
768
  try {
344
769
  fs.mkdirSync(SCRATCH_DIR, { recursive: true })
345
770
  const reportPath = path.join(SCRATCH_DIR, `${result.cardId}.iterate.json`)
771
+ const bestIrPath = path.join(SCRATCH_DIR, `${result.cardId}.ir.best.json`)
346
772
  let prev = null
347
773
  try {
348
774
  prev = JSON.parse(fs.readFileSync(reportPath, 'utf8'))
@@ -351,33 +777,116 @@ function persistReport(result, { sp, dp }) {
351
777
  const hotSig = (pd.hotspots || []).slice(0, 3).map((h) => `${h.x},${h.y}`).join('|')
352
778
  const history = Array.isArray(prev?.history) ? prev.history.slice(-9) : []
353
779
  const last = history[history.length - 1]
354
- let stalledRounds = 1
355
- if (last && pd.match != null && last.match != null) {
356
- const sameScore = Math.abs(last.match - pd.match) < 0.3
357
- const sameHot = hotSig && last.hotSig === hotSig
358
- if (sameScore && sameHot) stalledRounds = (prev?.stalled?.rounds || 1) + 1
359
- }
360
- history.push({
780
+ const round = (last?.round || history.length) + 1
781
+ const snapshot = snapshotIR(result.cardId, irPath, round)
782
+ // 历史最好成绩(不含本轮):停滞判据的基准。
783
+ // 必须读上一份报告里**单独持久化**的 best —— history 只留最近 9 条,
784
+ // 最好成绩一旦被窗口挤出(实测 r10 的最好成绩到 r20 就没了),判据就会误判成「刚刷新过」。
785
+ const prevBest = prev?.best && Number.isFinite(prev.best.match) ? prev.best : null
786
+ // 注意:这里必须用 bestScore(= match − 6×缺要素)而不是裸 match,否则
787
+ // 「基准」与下面 isBetterThan 的口径不一致 —— 实测会出现「基准选了个 51.71 的塌陷轮次,
788
+ // 然后拿它当回退目标」的错位。
789
+ const bestBefore = [prevBest, ...history]
790
+ .filter((h) => h && h.match != null)
791
+ .reduce((a, h) => (a == null || bestScore(h) > bestScore(a) ? h : a), null)
792
+ // 要素线(内容真值):设计稿的文案是固定要素,迭代只许改样式布局。
793
+ // 记进每轮历史,才能看出「越改越少节点/要素」这种下滑(用户实测反馈的形态)。
794
+ const fid = result.fidelity || null
795
+ const cur = {
361
796
  ts: Date.now(),
797
+ round,
362
798
  match: pd.match ?? null,
799
+ elementsTotal: fid?.total ?? null,
800
+ elementsMatched: fid?.matched ?? null,
801
+ elementsMissing: Array.isArray(fid?.missing) ? fid.missing.length : null,
802
+ elementsInvented: Array.isArray(fid?.invented) ? fid.invented.length : null,
363
803
  heightDelta: pd.size?.heightDelta ?? null,
364
804
  contentOffset1x: result.contentOffset1x || null,
365
805
  lintErrors: result.lint?.errors ?? null,
366
806
  validateErrors: result.validate?.errors ?? null,
367
807
  hotSig,
368
808
  sets: (result.appliedSets || []).map((a) => a.path),
369
- })
809
+ irSnapshot: snapshot?.name || null,
810
+ }
811
+ history.push(cur)
812
+ // 最好成绩的判据:`score = match − 6×缺要素`(同量纲,见 MISSING_PENALTY 注释)。
813
+ // 为什么不纯比 match:要素是设计稿给定的真值,把真值换成占位文案(或删掉节点)会让 match 反而升——
814
+ // 实测 2026-09-21 demo 回合「产品名/利率/期限整批换成自编占位值」后像素照样 89%。
815
+ // 为什么不字典序优先要素:同一回合的 r1(要素齐 / 51.71 / 高差 800)会被判成优于
816
+ // r4(缺 2 条 / 89.71 / 高差 20),回退目标直接指向塌掉的结构。
817
+ const improved = bestBefore != null && isBetterThan(cur, bestBefore)
818
+ const best = bestBefore == null || improved ? cur : bestBefore
819
+ if (best === cur && irPath && fs.existsSync(irPath)) {
820
+ try {
821
+ fs.copyFileSync(irPath, bestIrPath)
822
+ } catch {}
823
+ }
824
+ const roundsSinceBest = best?.round ? round - best.round : 0
825
+ const regressed = Boolean(last && cur.match != null && last.match != null && cur.match < last.match - REGRESS_DELTA)
826
+ const stalledByMatch = cur.match != null && best?.match != null && roundsSinceBest >= STALL_ROUNDS
827
+ // 高度线:连续 HEIGHT_FROZEN_ROUNDS 轮 heightDelta 一模一样且不为 0。
828
+ // 只在门禁确实卡在高度上时才升格为停滞(heightDelta 已经在别的轮次里动过就不算)。
829
+ const heightSeq = history.map((h) => h.heightDelta).filter((v) => v != null)
830
+ const heightFrozen =
831
+ heightSeq.length >= HEIGHT_FROZEN_ROUNDS &&
832
+ Math.abs(heightSeq[heightSeq.length - 1]) >= HEIGHT_FROZEN_MIN_PX &&
833
+ heightSeq.slice(-HEIGHT_FROZEN_ROUNDS).every((v) => v === heightSeq[heightSeq.length - 1])
834
+ // 两条线各自独立触发:match 不刷新最好成绩,或高度差显著地冻住不动。
835
+ // 后者单独判停是因为门禁要求 heightDelta=0 —— 高度冻着不动时,再调像素也永远过不了。
836
+ const stalledBy = stalledByMatch ? 'match' : heightFrozen ? 'height' : null
837
+ const stalled = Boolean(stalledBy)
370
838
  const persisted = {
371
839
  ...result,
372
840
  elements: undefined,
373
841
  schemaHash: hashFile(sp),
374
842
  dataHash: hashFile(dp),
375
843
  history,
376
- stalled: stalledRounds >= 2 ? { rounds: stalledRounds } : undefined,
844
+ best: best
845
+ ? {
846
+ match: best.match,
847
+ // 排序分一起落盘:下一轮据此比「谁才是真正的最好成绩」,与 isBetterThan 同口径
848
+ score: bestScore(best),
849
+ round: best.round,
850
+ // 要素线一起落盘:下一轮据此判「这轮是不是靠丢要素换的像素分」
851
+ elementsMissing: best.elementsMissing ?? null,
852
+ elementsMatched: best.elementsMatched ?? null,
853
+ irSnapshot: best.irSnapshot || null,
854
+ ts: best.ts || null,
855
+ }
856
+ : undefined,
857
+ stalled: undefined,
377
858
  }
378
859
  persisted.gate = evaluateGate(persisted)
860
+ if (stalled) {
861
+ persisted.stalled = {
862
+ reason: stalledBy,
863
+ rounds: roundsSinceBest,
864
+ best: best
865
+ ? { match: best.match, round: best.round, elementsMissing: best.elementsMissing ?? null, irSnapshot: best.irSnapshot }
866
+ : null,
867
+ regressed,
868
+ bestIr: fs.existsSync(bestIrPath) ? bestIrPath : null,
869
+ hint: stalledHint({
870
+ cardId: result.cardId,
871
+ roundsSinceBest,
872
+ best,
873
+ cur,
874
+ last,
875
+ regressed,
876
+ gate: persisted.gate,
877
+ irRel: irPath ? path.relative(repoRoot, path.resolve(irPath)) : '',
878
+ reason: stalledBy,
879
+ }),
880
+ }
881
+ }
882
+ // 显式落结论:一张卡目前处于哪种状态,必须一眼可读。
883
+ // 旧版只有 gate.passed 一个布尔——「没过但也没判停滞」时,模型可以直接停手、界面上
884
+ // 却看不出这卡是「还在迭代」还是「被放弃了」(实测 2 轮后无声转向下一张卡)。
885
+ persisted.conclusion = persisted.gate.passed ? 'pass' : stalledBy ? 'stalled' : 'iterating'
379
886
  fs.writeFileSync(reportPath, JSON.stringify(persisted, null, 2) + '\n')
380
- return { reportPath, stalled: persisted.stalled, gate: persisted.gate, rounds: history.length }
887
+ // 轮次快照只留最近 12 轮 + best:这是个「每轮一份 IR 全文」的目录,长任务下会无限堆积
888
+ const pruned = pruneSnapshots(result.cardId)
889
+ return { reportPath, stalled: persisted.stalled, gate: persisted.gate, conclusion: persisted.conclusion, rounds: history.length, pruned }
381
890
  } catch {
382
891
  return null
383
892
  }
@@ -387,6 +896,10 @@ function persistReport(result, { sp, dp }) {
387
896
  async function runPipeline({ ir: irPath, out: outDir, design: designPath, width, session, alwaysDiffImage, withElements, sets, specPath, skipPreflight, slicesDir: slicesDirArg }) {
388
897
  const t0 = performance.now()
389
898
 
899
+ // 设计稿侧元素(供 contributors 的 designH/deltaH):只有给了 --spec 才可用。
900
+ // 拿不到就不给 deltaH —— 宁缺勿滥,编不出可证伪的数字比没有数字更坏。
901
+ const designSide = loadDesignElements(specPath, designPath)
902
+
390
903
  // 1. Read IR(可选 --set 编辑,原子写回)→ expand
391
904
  let ir
392
905
  let appliedSets = null
@@ -434,17 +947,30 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
434
947
  const errs = Array.isArray(v.errors) ? v.errors : []
435
948
  const warns = Array.isArray(v.warnings) ? v.warnings : []
436
949
  validateReport = { ok: !!v.ok, errors: errs.length, warnings: warns.length, details: errs.slice(0, 5) }
950
+ // 没找到可用 python ⇒ validate 根本没跑。带着 envError 进报告,门禁据此判「环境阻塞」,
951
+ // 不让模型把「装 Python 才能解决的事」当成「再调几轮就能过」的门禁缺口。
952
+ if (v.envError) validateReport.envError = true
437
953
  } catch (e) {
438
954
  validateReport = { ok: false, errors: 1, warnings: 0, details: [`validate 执行异常: ${e.message || e}`] }
439
955
  }
440
956
 
441
957
  // 4. Render (reuse session if provided, else cold start)
442
- const renderWidth = width || Number(schema.view?.width) || DEFAULT_WIDTH
958
+ // 宽度优先级:显式 --width > 设计稿卡面宽(spec 是 1x,卡面如 343)> schema.view.width > 默认。
959
+ // 实测事故:第 44 次调用用了默认 375 跑完一轮,第 46 次才改成 343(设计稿 686 = 343×2)—— 白烧一轮。
960
+ const renderWidth = width || (designSide?.width ? designSide.width : 0) || Number(schema.view?.width) || DEFAULT_WIDTH
961
+ // 显式宽度与设计稿卡面不符时响亮告警:宽度决定像素对比基准,错了 match 会被整体压低、且逐带 dy 全歪
962
+ const widthMismatch =
963
+ width && designSide?.width && Math.abs(width - designSide.width) > 1
964
+ ? `--width ${width} 与设计稿卡面宽 ${designSide.width} 不一致(设计稿坐标是 1x):请改 --width ${designSide.width},否则像素对比基准不对、match 与 dy 都不可信。`
965
+ : null
966
+ // 归因需要逐元素度量:有设计稿时默认抓(一次 Runtime.evaluate,几十毫秒),
967
+ // 相比「没有归因 ⇒ 模型整份重写 IR」的代价可以忽略
968
+ const captureElements = !!withElements || !!designPath
443
969
  let renderRes
444
970
  if (session) {
445
- renderRes = await session.render({ id: cardId, width: renderWidth, format: 'png', hotReload: true, captureElements: !!withElements })
971
+ renderRes = await session.render({ id: cardId, width: renderWidth, format: 'png', hotReload: true, captureElements })
446
972
  } else {
447
- renderRes = await renderCardScreenshot({ id: cardId, width: renderWidth, format: 'png', captureElements: !!withElements })
973
+ renderRes = await renderCardScreenshot({ id: cardId, width: renderWidth, format: 'png', captureElements })
448
974
  }
449
975
 
450
976
  if (renderRes.error) {
@@ -497,6 +1023,53 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
497
1023
  // 5.5 preflight(quickref Pre-flight Checklist 的机械化 10 条)并入本轮报告
498
1024
  const preflight = skipPreflight ? null : runPreflight({ cardId, specPath })
499
1025
 
1026
+ // 5.6 要素清单(内容真值)—— 只查得到就进报告,并作为**机器门禁的一项**(elements.missing=0)。
1027
+ //
1028
+ // 为什么必须进链路:实测(2026-09-21 demo 建卡回合)模型把设计稿的产品名 / 利率 / 期限
1029
+ // 全换成自己编的占位值(「理财优选1号 / 2.45 / 每日申购」),像素照样刷到 89%、
1030
+ // validate/lint 全绿,而卡片的内容真值**整批丢失**,直到人工比对设计稿才发现。
1031
+ // 根因是门禁只有「结构 + 像素」两维,没有「要素是否还在」这一维:
1032
+ // 设计稿的要素是固定真值,迭代只许改样式与布局 —— 丢了要素,再高的 match 也是错的。
1033
+ const fidelityReport = loadFidelityReport({ specPath, schemaPath: sp, designPath, cardId })
1034
+
1035
+ // 内容偏移折算到 1x 设计像素 + 对齐提示(先算好,contributors 的 hint 要用它)
1036
+ let contentOffset1x = null
1037
+ let contentOffsetHint = null
1038
+ if (pixelDiffResult?.contentOffset) {
1039
+ const scale = renderWidth ? (renderRes.width || renderWidth) / renderWidth : 2
1040
+ const co = pixelDiffResult.contentOffset
1041
+ contentOffset1x = {
1042
+ dx: Math.round(co.dx / (scale || 1)),
1043
+ dy: Math.round(co.dy / (scale || 1)),
1044
+ }
1045
+ const { dx, dy } = contentOffset1x
1046
+ if (Math.abs(dx) > GATE.contentOffset1x || Math.abs(dy) > GATE.contentOffset1x) {
1047
+ contentOffsetHint = `内容整体偏移 dx=${dx} dy=${dy}(1x 设计像素,渲染相对设计)——这是对齐问题不是间距微调问题:先查 Root/首层容器的左右与上下 padding、首个子节点的 margin、以及是否漏了 marginType/paddingType:"1";继续逐行调 gap/padding 不会收敛。`
1048
+ }
1049
+ }
1050
+
1051
+ // 分项归因:把热区落到具体节点。这是「门禁说差多少」之外唯一能让模型不盲改的东西
1052
+ const contributors = attributeContributors({
1053
+ hotspots: pixelDiffResult?.hotspots,
1054
+ elements: renderRes.elementBoxes,
1055
+ design: designSide,
1056
+ contentOffset1x,
1057
+ })
1058
+
1059
+ // 5.7 纯几何审计(隐形/出卡/折行)——「模型看不见渲染图」的机器替代品。
1060
+ //
1061
+ // 背景(2026-09-21):card_screenshot 只给人看、不进模型上下文,模型可观测输入只有
1062
+ // match/heightDelta/bands 三个数字,于是 Row 内横向容器默认 100% 吃掉整行这类
1063
+ // 「几何上确凿、像素上模糊」的缺陷被拖了 30 分钟。这三条判定不需要看图:
1064
+ // 被压成 0 尺寸、跑出卡片右缘、文本被折成一堆行,全是 getBoundingClientRect 的事实。
1065
+ const geometry = renderRes.elementBoxes
1066
+ ? auditGeometry({
1067
+ elements: renderRes.elementBoxes,
1068
+ degenerate: renderRes.elementBoxesDegenerate,
1069
+ cardWidth: renderRes.width,
1070
+ })
1071
+ : null
1072
+
500
1073
  const result = {
501
1074
  ok: true,
502
1075
  cardId,
@@ -524,34 +1097,39 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
524
1097
  : undefined,
525
1098
  designImage,
526
1099
  preflight: preflight || undefined,
1100
+ fidelity: fidelityReport || undefined,
1101
+ // 尺寸口径警告:宽度不对时下面所有像素指标都不可信,必须最先修
1102
+ widthMismatch: widthMismatch || undefined,
1103
+ contentOffset1x: contentOffset1x || undefined,
1104
+ contentOffsetHint: contentOffsetHint || undefined,
1105
+ // 分项归因:每个热区给出「最可能的节点 + 它自己实测的 h / 同类中位 / 设计稿高度」
1106
+ contributors: contributors || undefined,
1107
+ // 纯几何隐形节点清单:不依赖看图,直接列出「用户看不见的东西」及其成因
1108
+ geometry: geometry || undefined,
1109
+ geometryNote: geometry
1110
+ ? 'geometry 是本轮渲染的纯几何审计(round1 使用 1x 设计像素):collapsed = 被压成零尺寸、outOfCard = 跑出卡片被裁、overWrapped = 文本折行数异常。**门禁只把 collapsed / outOfCard 当 ERROR**(这两类用户必然看不见);逐条按 cid 去 schema 里改那一处布局,别整份重写。'
1111
+ : undefined,
1112
+ contributorsNote: contributors
1113
+ ? 'contributors 按热区给出最可能出问题的**具体节点**(cid/cls/text):先改最内层那个节点的 padding/lineHeight,一轮只改一项;flags 里 vs-siblings = 与该类元素中位高度不同,vs-design = 与设计稿高度不同,text-clipped = 文本被裁剪。'
1114
+ : undefined,
527
1115
  ...(withElements ? { elements: renderRes.elementBoxes || [] } : {}),
528
1116
  elapsedMs: elapsed,
529
1117
  }
530
1118
 
531
- // 内容偏移折算到 1x 设计像素 + 对齐提示
532
- if (pixelDiffResult?.contentOffset) {
533
- const scale = renderWidth ? (renderRes.width || renderWidth) / renderWidth : 2
534
- const co = pixelDiffResult.contentOffset
535
- result.contentOffset1x = {
536
- dx: Math.round(co.dx / (scale || 1)),
537
- dy: Math.round(co.dy / (scale || 1)),
538
- }
539
- const { dx, dy } = result.contentOffset1x
540
- if (Math.abs(dx) > GATE.contentOffset1x || Math.abs(dy) > GATE.contentOffset1x) {
541
- result.contentOffsetHint = `内容整体偏移 dx=${dx} dy=${dy}(1x 设计像素,渲染相对设计)——这是对齐问题不是间距微调问题:先查 Root/首层容器的左右与上下 padding、首个子节点的 margin、以及是否漏了 marginType/paddingType:"1";继续逐行调 gap/padding 不会收敛。`
542
- }
543
- }
544
-
545
1119
  // 报告落盘 + 门禁判定 + 停滞检测
546
- const saved = persistReport(result, { sp, dp })
1120
+ const saved = persistReport(result, { sp, dp, irPath })
547
1121
  if (saved) {
548
1122
  result.report = saved.reportPath
549
1123
  result.rounds = saved.rounds
550
1124
  result.gate = saved.gate
1125
+ // 显式结论一路带到工具输出:模型与用户都能一眼看出这卡是 pass / stalled / 还在 iterating,
1126
+ // 而不是只拿到一个 passed:false 的布尔(旧版因此出现「2 轮后无声转向下一张卡」)。
1127
+ result.conclusion = saved.conclusion
551
1128
  if (saved.stalled) {
1129
+ // hint 由 persistReport 生成(那里才有历史与 bestIr 路径),这里只按设计稿路径补全 diff_bands 命令
552
1130
  result.stalled = {
553
- rounds: saved.stalled.rounds,
554
- hint: `连续 ${saved.stalled.rounds} 轮 match 与热区几乎没变:--set 微调已无收益,禁止继续盲调。改按证据推进——① 跑 diff_bands.mjs(args=["--id","${cardId}","--design","${designPath || '<设计稿>'}","--render","${renderImagePath || '<renderImage>'}"])拿逐带 dy/dh、内容底边、分区主色;② 对照 references/generation_pitfalls.md 的「类型开关+值」「静默丢弃」清单排查样式被忽略(marginType/paddingType/backgroundType/isBorder/isShadow);③ 读报告里的 contentOffset1x 与 hotspots 判断是整体平移还是局部差异;④ 有 --spec 时看 preflight.details。`,
1131
+ ...saved.stalled,
1132
+ hint: String(saved.stalled.hint || '').replaceAll('<设计稿>', designPath || '<设计稿>').replaceAll('<本轮 renderImage>', renderImagePath || '<renderImage>'),
555
1133
  }
556
1134
  }
557
1135
  }
@@ -581,12 +1159,14 @@ function parseArgs(argv) {
581
1159
  console.log(`Usage: node iterate.mjs --ir <IR.json> --out <cardDir> [--design <design.png>] [--width 343] [--elements]
582
1160
  [--spec <spec.json>](跑 preflight 元素清单比对) [--no-preflight] [--slices-dir <要素包 slices 目录>]
583
1161
  [--set 'children.0.s.margin.marginTop="7"'](可重复;点路径数字段=数组下标;原子写回 IR)
584
- node iterate.mjs --gate --out <cardDir>(或 --id <cardId>):读最近一轮落盘报告做机器门禁判定,未达标 exit 1
1162
+ node iterate.mjs --gate --out <cardDir>(或 --id <cardId>):读最近一轮落盘报告做机器门禁判定,未达标 exit 2(gate-fail,不是脚本报错)
585
1163
  node iterate.mjs --keep-alive (stdin JSON lines → stdout JSON lines)
586
1164
 
587
1165
  门禁(--gate / 报告里的 gate 字段):validate.errors=0 ∧ lint.errors=0 ∧ preflight.errors=0 ∧ match>=${GATE.match}
588
- ∧ heightDelta=${GATE.heightDelta} ∧ widthDelta=${GATE.widthDelta} ∧ |contentOffset1x|<=${GATE.contentOffset1x},且 schema 未在报告之后被改过。
589
- 报告落盘:.cache/ai-scratch/<cardId>.iterate.json(含 gate / history / stalled)。`)
1166
+ ∧ heightDelta=${GATE.heightDelta} ∧ widthDelta=${GATE.widthDelta} ∧ |contentOffset1x|<=${GATE.contentOffset1x}
1167
+ ∧ geometry.collapsed=0 ∧ geometry.outOfCard=0(纯几何隐形项:被压成零尺寸 / 跑出卡片被裁),
1168
+ 且 schema 未在报告之后被改过。
1169
+ 报告落盘:.cache/ai-scratch/<cardId>.iterate.json(含 gate / history / stalled / geometry)。`)
590
1170
  process.exit(0)
591
1171
  }
592
1172
  }
@@ -620,9 +1200,22 @@ function runGateMode({ id, out: outDir }) {
620
1200
  gate.verdict = `FAIL:报告已过期(schema.json 在报告之后被改过:${report.schemaHash} → ${schemaHash})—— 重跑一轮 iterate.mjs 再判门禁`
621
1201
  gate.checks.push({ name: 'reportFreshness', value: 'stale', required: 'schema 未变更', ok: false })
622
1202
  }
1203
+ // 显式结论:pass / stalled / iterating。三者之外不允许「无声退出」——
1204
+ // 实测建卡回合里模型跑完 2 轮就直接转去做下一张卡,卡在「既没过门禁、也没判停滞」的
1205
+ // 中间态,界面上完全看不出这卡是被放弃了还是还在迭代。
1206
+ const conclusion = gate.passed ? 'pass' : report.stalled ? 'stalled' : 'iterating'
1207
+ if (conclusion === 'iterating') {
1208
+ gate.verdict +=
1209
+ ';结论=iterating:既未过门禁、也未判停滞 —— 不允许无声退出。' +
1210
+ '要么继续 iterate(一轮只改一项,拿到新报告再判),要么在正文里显式写明放弃理由(例:设计稿本身自相矛盾 / 缺关键切图)再停手。'
1211
+ }
623
1212
  const out = {
624
1213
  ok: gate.passed,
625
1214
  cardId,
1215
+ conclusion,
1216
+ stalled: report.stalled
1217
+ ? { reason: report.stalled.reason || 'match', rounds: report.stalled.rounds, hint: report.stalled.hint }
1218
+ : null,
626
1219
  report: reportPath,
627
1220
  gate,
628
1221
  summary: {
@@ -631,6 +1224,7 @@ function runGateMode({ id, out: outDir }) {
631
1224
  widthDelta: report.pixelDiff?.size?.widthDelta ?? null,
632
1225
  contentOffset1x: report.contentOffset1x || null,
633
1226
  validateErrors: report.validate?.errors ?? null,
1227
+ envBlocked: report.validate?.envError === true ? true : undefined,
634
1228
  lintErrors: report.lint?.errors ?? null,
635
1229
  preflightErrors: report.preflight?.errors ?? null,
636
1230
  rounds: (report.history || []).length,
@@ -638,7 +1232,10 @@ function runGateMode({ id, out: outDir }) {
638
1232
  },
639
1233
  }
640
1234
  console.log(JSON.stringify(out, null, 2))
641
- process.exit(gate.passed ? 0 : 1)
1235
+ // 门禁未达标 = 正常诊断结果(gate-fail),不是「脚本报错收尾」。
1236
+ // 必须与 exit 1 分开:服务端按 exit 语义记账,exit 1 会被记成「连续失败」并触发短路
1237
+ // (实测 2026-09-21 demo 建卡回合:--gate 被误判失败 1 次,模型因此被误导去改别的地方)。
1238
+ process.exit(gate.passed ? 0 : 2)
642
1239
  }
643
1240
 
644
1241
  async function main() {
@@ -694,7 +1291,12 @@ async function main() {
694
1291
  if (result.gate && result.gate.passed === false) process.exitCode = 2
695
1292
  }
696
1293
 
697
- main().catch((e) => {
698
- console.error(`iterate.mjs 未预期错误: ${e.message || e}`)
699
- process.exit(1)
700
- })
1294
+ // 仅当作为 CLI 直接运行时才启动 main()(被 import 时不执行:停滞判据可离线重放验证)
1295
+ const _thisScript = fs.realpathSync(fileURLToPath(import.meta.url))
1296
+ const _invokedAs = process.argv[1] && fs.existsSync(process.argv[1]) ? fs.realpathSync(process.argv[1]) : process.argv[1]
1297
+ if (_invokedAs === _thisScript) {
1298
+ main().catch((e) => {
1299
+ console.error(`iterate.mjs 未预期错误: ${e.message || e}`)
1300
+ process.exit(1)
1301
+ })
1302
+ }