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.
- package/dist/cli.js +0 -0
- package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
- package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/mcp/client.mjs +8 -1
- package/dist/vendored/server/core/ai-chat.js +1521 -124
- package/dist/vendored/server/core/browser.js +12 -3
- package/dist/vendored/server/core/cardFiles.js +3 -0
- package/dist/vendored/server/core/cardHistory.js +1 -0
- package/dist/vendored/server/core/cardImportMeta.js +7 -6
- package/dist/vendored/server/core/cardOpenFolder.js +1 -0
- package/dist/vendored/server/core/cardSave.js +1 -0
- package/dist/vendored/server/core/cardSync.js +3 -0
- package/dist/vendored/server/core/codeBridge.js +5 -1
- package/dist/vendored/server/core/logStream.js +1 -0
- package/dist/vendored/server/core/sliceStatic.js +24 -4
- package/dist/vendored/server/core/sliceUpload.js +1 -0
- package/dist/vendored/server/core/turnJournal.js +6 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
- package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
- package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
554
|
-
hint:
|
|
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
|
|
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}
|
|
589
|
-
|
|
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
|
-
|
|
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()
|
|
698
|
-
|
|
699
|
-
|
|
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
|
+
}
|