cardbuddy 0.14.3 → 0.16.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-BkbBxDTT.css +1 -0
- package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/card-editor/runtime/adapter.js +2 -2
- package/dist/vendored/mcp/client.mjs +9 -2
- package/dist/vendored/server/core/ai-chat.js +1702 -134
- 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/cardValidate.js +16 -5
- 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 +257 -201
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
- package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
- package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
- package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
- 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 +44 -8
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -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 +6 -6
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -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 +1015 -54
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -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 +5727 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
- 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 +188 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
- package/dist/vendored/skills/shared/exit-codes.md +79 -0
- package/dist/vendored/skills/shared/spec-contract.md +118 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
- package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
- package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
- package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
- package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
|
@@ -36,23 +36,37 @@ 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'
|
|
46
|
+
import { buildReadinessReport } from './readiness.mjs'
|
|
43
47
|
import { createRenderSession, renderCardScreenshot } from './render_card.mjs'
|
|
44
48
|
import { copyCardDesignImage } from './lib/design_image.mjs'
|
|
45
|
-
import { cardsDir, cardSchemaPath, cardDataPath, readJson, repoRoot } from './lib/paths.mjs'
|
|
49
|
+
import { cardsDir, cardSchemaPath, cardDataPath, readJson, repoRoot, scratchDir as SCRATCH_DIR } from './lib/paths.mjs'
|
|
46
50
|
|
|
47
51
|
const { PNG } = await import('pngjs')
|
|
48
52
|
|
|
49
53
|
const DEFAULT_DIFF_DIR = path.join(repoRoot, '.card-diffs')
|
|
50
54
|
const DEFAULT_WIDTH = 375
|
|
51
|
-
/** 报告落盘目录:门禁(--gate
|
|
52
|
-
|
|
55
|
+
/** 报告落盘目录:门禁(--gate)只认这里的机器产物,不认模型自述。
|
|
56
|
+
* 路径单源在 lib/paths.mjs(check_fidelity / readiness 也读同一份 —— 历史事故:
|
|
57
|
+
* check_fidelity 自己拼 `<cardDir>/.audit.json`,真实位置却是 `.cache/ai-scratch/`)。 */
|
|
53
58
|
const PREFLIGHT_SCRIPT = path.join(repoRoot, 'skills', 'dsl-card-generator', 'scripts', 'preflight.mjs')
|
|
54
|
-
/** 收敛门禁阈值(协议 S5/S6
|
|
55
|
-
|
|
59
|
+
/** 收敛门禁阈值(协议 S5/S6 的机器化版本,改这里即改门禁)。
|
|
60
|
+
* match 是**唯一权威**:SKILL.md 只引用这里的语义,不复制具体数字,
|
|
61
|
+
* 避免「文档写 85、代码判 95」导致模型提前收工被 --gate 判 FAIL 返工。
|
|
62
|
+
* 可用环境变量 DSL_CARD_GATE_MATCH 覆盖(非法/空值回落默认)。 */
|
|
63
|
+
const GATE_MATCH_OVERRIDE = Number(process.env.DSL_CARD_GATE_MATCH)
|
|
64
|
+
const GATE = {
|
|
65
|
+
match: Number.isFinite(GATE_MATCH_OVERRIDE) && GATE_MATCH_OVERRIDE > 0 ? GATE_MATCH_OVERRIDE : 95,
|
|
66
|
+
heightDelta: 0,
|
|
67
|
+
widthDelta: 0,
|
|
68
|
+
contentOffset1x: 2,
|
|
69
|
+
}
|
|
56
70
|
|
|
57
71
|
/* ── IR 原子编辑(--set)────────────────────────────────────
|
|
58
72
|
* 点路径寻址,数字段按数组下标:children.0.s.margin.marginTop="7"
|
|
@@ -227,6 +241,268 @@ function diffHotspots(renderPng, designPng, { cols = 4, rows = 8, floorPct = 3,
|
|
|
227
241
|
return cells.slice(0, maxCells)
|
|
228
242
|
}
|
|
229
243
|
|
|
244
|
+
/* ── 结构性缺口(structuralGaps):逐行几乎全异的连续带区 ──
|
|
245
|
+
* 存在理由(gen-1 实测):雷达图区块整块漏建 / 背景整卡错涂时,hotspots 只给碎片坐标、
|
|
246
|
+
* contributors 把注意力引到「10分/分」这类局部微调,模型调了 8 轮 padding/gap 也没碰到根因。
|
|
247
|
+
* 口径:逐行统计 design vs render 的像素差异率(色距 >30),连续 ≥40pt(1x)且差异率 ≥85%
|
|
248
|
+
* 的带区记为缺口带——要么该带内容整块漏建,要么底色/背景在该带整层错涂;只报事实不猜因。 */
|
|
249
|
+
export function detectStructuralGaps(renderPng, designPng, { scale = 2, minGapPt = 40, maxGaps = 3, rowDiff = 0.85 } = {}) {
|
|
250
|
+
const h = Math.min(renderPng.height, designPng.height)
|
|
251
|
+
const w = Math.min(renderPng.width, designPng.width)
|
|
252
|
+
const step = 2
|
|
253
|
+
const rows = []
|
|
254
|
+
for (let y = 0; y < h; y += step) {
|
|
255
|
+
let diff = 0
|
|
256
|
+
let seen = 0
|
|
257
|
+
for (let x = 0; x < w; x += step) {
|
|
258
|
+
const i = (y * w + x) * 4
|
|
259
|
+
const dr = Math.abs(renderPng.data[i] - designPng.data[i])
|
|
260
|
+
const dg = Math.abs(renderPng.data[i + 1] - designPng.data[i + 1])
|
|
261
|
+
const db = Math.abs(renderPng.data[i + 2] - designPng.data[i + 2])
|
|
262
|
+
if (Math.max(dr, dg, db) > 30) diff++
|
|
263
|
+
seen++
|
|
264
|
+
}
|
|
265
|
+
rows.push({ y, ratio: diff / seen })
|
|
266
|
+
}
|
|
267
|
+
// 设计稿行墨量(供归因参考:该带在设计稿里有多少内容)
|
|
268
|
+
const isInk = (png, x, y) => {
|
|
269
|
+
const i = (y * png.width + x) * 4
|
|
270
|
+
if (png.data[i + 3] < 8) return false
|
|
271
|
+
return Math.abs(255 - png.data[i]) + Math.abs(255 - png.data[i + 1]) + Math.abs(255 - png.data[i + 2]) > 48
|
|
272
|
+
}
|
|
273
|
+
const gaps = []
|
|
274
|
+
let cur = null
|
|
275
|
+
for (const r of rows) {
|
|
276
|
+
if (r.ratio >= rowDiff) {
|
|
277
|
+
if (!cur) cur = { y0: r.y, y1: r.y, ink: [] }
|
|
278
|
+
else cur.y1 = r.y
|
|
279
|
+
cur.ink.push(isInk(designPng, Math.floor(w / 2), r.y) ? 1 : 0)
|
|
280
|
+
} else if (cur) {
|
|
281
|
+
gaps.push(cur)
|
|
282
|
+
cur = null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (cur) gaps.push(cur)
|
|
286
|
+
return gaps
|
|
287
|
+
.filter((g) => (g.y1 - g.y0) / scale >= minGapPt)
|
|
288
|
+
.sort((a, b) => (b.y1 - b.y0) - (a.y1 - a.y0))
|
|
289
|
+
.slice(0, maxGaps)
|
|
290
|
+
.map((g) => ({
|
|
291
|
+
y: Math.round(g.y0 / scale),
|
|
292
|
+
h: Math.round((g.y1 - g.y0) / scale),
|
|
293
|
+
designInkPct: Number(((g.ink.reduce((a, b) => a + b, 0) / g.ink.length) * 100).toFixed(1)),
|
|
294
|
+
}))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ── 分项归因(contributors):把差异热区落到具体渲染节点 ─────────
|
|
298
|
+
* 存在理由(2026-09-21 实测):那一轮 23 次 iterate 的报告只说「match 90.04、高度差 2px」,
|
|
299
|
+
* 从不说是**哪个节点**差 —— 模型只能整份重写 IR 去试,16 次全量重写、12.4 万字符、约占全部输出的一半。
|
|
300
|
+
* 能力其实齐了(render_card.mjs 的 elementBoxes 逐元素度量、iterate 的 --elements),
|
|
301
|
+
* 缺的只是「热区 → 元素」这一步映射。
|
|
302
|
+
*
|
|
303
|
+
* 坐标系:hotspots 已由 diffHotspots 折算到 1x 卡内坐标,elementBoxes 本就是 .phone__screen
|
|
304
|
+
* 相对 CSS 像素 —— 两者同系,可直接相交。spec.json 的元素是**画板坐标**(1x,如 375 画板),
|
|
305
|
+
* 需减去 card.rect 的 (x,y) 平移到卡内。
|
|
306
|
+
*
|
|
307
|
+
* 归因原则:只给**可证伪的事实**(元素自己实测的 h / scrollH、同类中位高度、设计稿高度),
|
|
308
|
+
* 不猜「疑似哪个属性」以外的因果;宁缺勿滥 —— 匹配不上的元素就不给 designH。
|
|
309
|
+
*/
|
|
310
|
+
const CONTRIB_MAX_HOTSPOTS = 5
|
|
311
|
+
const CONTRIB_MAX_ELEMENTS = 3
|
|
312
|
+
/** 元素主体落在热区内的最小比例:低于此值说明只是擦边,不算这个热区的成因 */
|
|
313
|
+
const CONTRIB_SELF_RATIO = 0.5
|
|
314
|
+
/** 与设计稿文本匹配时允许的最大纵向位移(卡内 1x px):超过就当同名不同节点,不给 deltaH */
|
|
315
|
+
const CONTRIB_MATCH_DY = 24
|
|
316
|
+
|
|
317
|
+
const r1 = (n) => Math.round(n * 10) / 10
|
|
318
|
+
const normText = (t) => String(t ?? '').replace(/\s+/g, ' ').trim()
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 读设计稿侧的卡内元素(供 deltaH)。
|
|
322
|
+
* 卡索引按 `--design` 的 `card-N.png` 推断(多卡画板的聚焦裁切命名约定),缺省第一张。
|
|
323
|
+
*/
|
|
324
|
+
/**
|
|
325
|
+
* 跑一次要素清单比对(spec 文本 → schema 节点),失败不阻断本轮(宁缺勿滥:拿不到就不给门禁项)。
|
|
326
|
+
* 卡片序号与 loadDesignElements 同源(设计稿文件名 card-N.png),保证「比的是同一张卡」。
|
|
327
|
+
*/
|
|
328
|
+
export function loadFidelityReport({ specPath, schemaPath, designPath, cardId }) {
|
|
329
|
+
if (!specPath || !schemaPath || !fs.existsSync(specPath) || !fs.existsSync(schemaPath)) return null
|
|
330
|
+
try {
|
|
331
|
+
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'))
|
|
332
|
+
const cards = Array.isArray(spec.cards) ? spec.cards : []
|
|
333
|
+
const m = /card-(\d+)\.png$/i.exec(String(designPath || ''))
|
|
334
|
+
const idx = m ? Math.max(0, Math.min(cards.length - 1, Number(m[1]) - 1)) : 0
|
|
335
|
+
// cardId 显式传入:schema 可能写在临时 out 目录(`--out /tmp/...`),
|
|
336
|
+
// 而审计落点按**卡片 id** 命名(.cache/ai-scratch/<id>.audit.json),靠目录名推会取不到。
|
|
337
|
+
const r = buildFidelityReport({ spec: specPath, schema: schemaPath, card: idx + 1, id: cardId || '' })
|
|
338
|
+
return {
|
|
339
|
+
cardId,
|
|
340
|
+
total: r.elements.texts.total,
|
|
341
|
+
matched: r.elements.texts.matched,
|
|
342
|
+
missing: r.elements.texts.missing,
|
|
343
|
+
invented: r.elements.texts.invented,
|
|
344
|
+
slices: r.elements.slices,
|
|
345
|
+
// 全类型要素缺口(2026-09-25):图标 / 分割线数量对账(warning 级信息项)
|
|
346
|
+
symbols: r.elements.symbols,
|
|
347
|
+
shapes: r.elements.shapes,
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function loadDesignElements(specPath, designPath) {
|
|
355
|
+
if (!specPath || !fs.existsSync(specPath)) return null
|
|
356
|
+
let spec
|
|
357
|
+
try {
|
|
358
|
+
spec = JSON.parse(fs.readFileSync(specPath, 'utf8'))
|
|
359
|
+
} catch {
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
const cards = Array.isArray(spec.cards) ? spec.cards : []
|
|
363
|
+
if (!cards.length) return null
|
|
364
|
+
const m = /card-(\d+)\.png$/i.exec(String(designPath || ''))
|
|
365
|
+
const idx = m ? Math.max(0, Math.min(cards.length - 1, Number(m[1]) - 1)) : 0
|
|
366
|
+
const card = cards[idx]
|
|
367
|
+
const cr = card?.rect
|
|
368
|
+
if (!cr) return null
|
|
369
|
+
const src = Array.isArray(card.elements) && card.elements.length ? card.elements : Array.isArray(spec.elements) ? spec.elements : []
|
|
370
|
+
const tx = Number(cr.x) || 0
|
|
371
|
+
const ty = Number(cr.y) || 0
|
|
372
|
+
return {
|
|
373
|
+
cardIndex: idx,
|
|
374
|
+
width: Number(cr.width) || 0,
|
|
375
|
+
height: Number(cr.height) || 0,
|
|
376
|
+
/** y 已平移到卡内坐标,与渲染元素同系 */
|
|
377
|
+
texts: src
|
|
378
|
+
.filter((e) => e?.rect && normText(e.text))
|
|
379
|
+
.map((e) => ({ text: normText(e.text), h: Number(e.rect.height) || 0, y: (Number(e.rect.y) || 0) - ty })),
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 分项归因:热区 → 覆盖率最高的最内层元素(+ 可选的设计稿高度差) */
|
|
384
|
+
export function attributeContributors({ hotspots, elements, design, contentOffset1x }) {
|
|
385
|
+
if (!Array.isArray(hotspots) || !hotspots.length) return null
|
|
386
|
+
const els = (elements || []).filter((e) => e && e.w > 0 && e.h > 0)
|
|
387
|
+
if (!els.length) return null
|
|
388
|
+
|
|
389
|
+
// 同类元素高度中位数:抓「同类里某一项高度异常」。整体偏移/缩放会同时影响同类,
|
|
390
|
+
// 所以这个判据不会把「整卡都差 2px」误报成「某个元素有问题」。
|
|
391
|
+
const hByCls = new Map()
|
|
392
|
+
for (const e of els) {
|
|
393
|
+
const k = e.cls || '?'
|
|
394
|
+
if (!hByCls.has(k)) hByCls.set(k, [])
|
|
395
|
+
hByCls.get(k).push(e.h)
|
|
396
|
+
}
|
|
397
|
+
const hMed = new Map()
|
|
398
|
+
for (const [k, arr] of hByCls) {
|
|
399
|
+
const s = [...arr].sort((a, b) => a - b)
|
|
400
|
+
hMed.set(k, s[Math.floor(s.length / 2)])
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 设计稿文本 → 高度(同文本多条时取 y 最近的一条,且位移要在容差内)
|
|
404
|
+
const designTexts = design?.texts || []
|
|
405
|
+
const pick = (e) => {
|
|
406
|
+
const t = normText(e.text)
|
|
407
|
+
if (!t) return null
|
|
408
|
+
let hit = null
|
|
409
|
+
for (const d of designTexts) {
|
|
410
|
+
if (d.text !== t) continue
|
|
411
|
+
const dy = Math.abs(d.y - e.y)
|
|
412
|
+
if (dy > CONTRIB_MATCH_DY) continue
|
|
413
|
+
if (!hit || dy < hit.dy) hit = { d, dy }
|
|
414
|
+
}
|
|
415
|
+
return hit
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const out = []
|
|
419
|
+
for (const hot of hotspots.slice(0, CONTRIB_MAX_HOTSPOTS)) {
|
|
420
|
+
const area = Math.max(1, hot.w * hot.h)
|
|
421
|
+
const inHot = []
|
|
422
|
+
for (const e of els) {
|
|
423
|
+
const ox = Math.max(0, Math.min(e.x + e.w, hot.x + hot.w) - Math.max(e.x, hot.x))
|
|
424
|
+
const oy = Math.max(0, Math.min(e.y + e.h, hot.y + hot.h) - Math.max(e.y, hot.y))
|
|
425
|
+
const inter = ox * oy
|
|
426
|
+
if (inter <= 0) continue
|
|
427
|
+
const selfRatio = inter / (e.w * e.h)
|
|
428
|
+
if (selfRatio < CONTRIB_SELF_RATIO) continue // 主体不在此热区,只是擦边
|
|
429
|
+
inHot.push({ e, inter })
|
|
430
|
+
}
|
|
431
|
+
if (!inHot.length) continue
|
|
432
|
+
// 面积升序 = 最具体的节点在前:热区里嵌套的父/子都命中时,最小的那个才是差异所在
|
|
433
|
+
inHot.sort((a, b) => a.e.w * a.e.h - b.e.w * b.e.h)
|
|
434
|
+
|
|
435
|
+
const picked = inHot.slice(0, CONTRIB_MAX_ELEMENTS).map(({ e, inter }) => {
|
|
436
|
+
const m = pick(e)
|
|
437
|
+
const dH = m ? r1(e.h - m.d.h) : null
|
|
438
|
+
const med = hMed.get(e.cls) ?? null
|
|
439
|
+
const flags = []
|
|
440
|
+
if (e.scrollH > e.clientH + 1) flags.push('text-clipped')
|
|
441
|
+
if (med != null && Math.abs(e.h - med) >= 1) flags.push(`vs-siblings ${r1(e.h - med) > 0 ? '+' : ''}${r1(e.h - med)}`)
|
|
442
|
+
if (dH != null && Math.abs(dH) >= 1) flags.push(`vs-design ${dH > 0 ? '+' : ''}${dH}`)
|
|
443
|
+
if (Math.abs(hot.y + hot.h - (e.y + e.h)) <= 2) flags.push('bottom-edge')
|
|
444
|
+
if (Math.abs(hot.x + hot.w - (e.x + e.w)) <= 2) flags.push('right-edge')
|
|
445
|
+
return {
|
|
446
|
+
cid: e.cid,
|
|
447
|
+
cls: e.cls,
|
|
448
|
+
text: String(e.text || '').slice(0, 24),
|
|
449
|
+
rect: [e.x, e.y, e.w, e.h],
|
|
450
|
+
h: e.h,
|
|
451
|
+
hSibling: med,
|
|
452
|
+
designH: m ? m.d.h : null,
|
|
453
|
+
dH,
|
|
454
|
+
hotCover: Math.round((inter / area) * 100) / 100,
|
|
455
|
+
flags,
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const top = picked[0]
|
|
460
|
+
const acts = []
|
|
461
|
+
if (top.flags.includes('text-clipped')) acts.push('该节点文本被裁剪(scrollH > clientH)⇒ 查 isMaxLine / maxLine / lineHeight')
|
|
462
|
+
if (top.flags.some((f) => f.startsWith('vs-design'))) acts.push(`该节点高度与设计稿差 ${top.dH} ⇒ 查 paddingTop/Bottom 与 lineHeight`)
|
|
463
|
+
if (top.flags.some((f) => f.startsWith('vs-siblings'))) acts.push(`该节点高度与同类中位(${top.hSibling})不同 ⇒ 查该节点的 padding/margin,而不是整层 gap`)
|
|
464
|
+
if (top.flags.includes('bottom-edge') || top.flags.includes('right-edge')) acts.push('热区贴该节点下/右边界 ⇒ 差异在边界外(下边距 / 圆角 / 阴影)或该节点比设计高')
|
|
465
|
+
if (!acts.length) acts.push('按热区内的具体节点逐个核对 padding / lineHeight / gap')
|
|
466
|
+
out.push({
|
|
467
|
+
hotspot: [hot.x, hot.y, hot.w, hot.h],
|
|
468
|
+
diffPct: hot.diffPct,
|
|
469
|
+
elements: picked,
|
|
470
|
+
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 + ',先修对齐再动这里' : ''}。`,
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
return out.length ? out : null
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 清除多余轮次快照:保留最近 keep 轮 + best(每次 iterate 收尾调用,避免无限堆积) */
|
|
477
|
+
function pruneSnapshots(cardId, keep = 12) {
|
|
478
|
+
try {
|
|
479
|
+
const names = fs
|
|
480
|
+
.readdirSync(SCRATCH_DIR)
|
|
481
|
+
.filter((n) => n.startsWith(`${cardId}.ir.r`) && n.endsWith('.json'))
|
|
482
|
+
if (names.length <= keep) return 0
|
|
483
|
+
let bestRound = 0
|
|
484
|
+
try {
|
|
485
|
+
bestRound = Number(JSON.parse(fs.readFileSync(path.join(SCRATCH_DIR, `${cardId}.iterate.json`), 'utf8'))?.best?.round) || 0
|
|
486
|
+
} catch {}
|
|
487
|
+
const roundOf = (n) => Number(/(\d+)\.json$/.exec(n)?.[1] || 0)
|
|
488
|
+
// 先按轮次降序,保留最近 keep 轮;best 那一轮额外豁免(回退要按 --round 直取文件名)
|
|
489
|
+
const doomed = names
|
|
490
|
+
.sort((a, b) => roundOf(b) - roundOf(a))
|
|
491
|
+
.slice(keep)
|
|
492
|
+
.filter((n) => roundOf(n) !== bestRound)
|
|
493
|
+
let n = 0
|
|
494
|
+
for (const f of doomed) {
|
|
495
|
+
try {
|
|
496
|
+
fs.unlinkSync(path.join(SCRATCH_DIR, f))
|
|
497
|
+
n++
|
|
498
|
+
} catch {}
|
|
499
|
+
}
|
|
500
|
+
return n
|
|
501
|
+
} catch {
|
|
502
|
+
return 0
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
230
506
|
/* ── 合成并排对比图(内存,可选写盘) ──────────────────────── */
|
|
231
507
|
function composeSideBySide(designBuf, renderBuf) {
|
|
232
508
|
const designPng = PNG.sync.read(designBuf)
|
|
@@ -291,7 +567,95 @@ function hashFile(p) {
|
|
|
291
567
|
}
|
|
292
568
|
}
|
|
293
569
|
|
|
294
|
-
/**
|
|
570
|
+
/**
|
|
571
|
+
* 停滞判据(2026-09-21 重写):以**历史最好成绩**为基准,看连续多少轮没有刷新它。
|
|
572
|
+
*
|
|
573
|
+
* 旧判据要求「match 与上轮几乎相同(|Δ|<0.3)**且** top-3 热区坐标全等」才累加,而实测形态是
|
|
574
|
+
* **震荡下滑**(79.6→92.42→90.04,13 轮里 11 轮更差)——热区每轮都在动,23 份报告里 stalled
|
|
575
|
+
* 出现 0 次,模型却按提示词在等这个永不出现的信号,一路盲调到 707 万 token 才被人工中止。
|
|
576
|
+
* 「没有更好」比「一模一样」才是真正该叫停的形态。
|
|
577
|
+
*/
|
|
578
|
+
const STALL_ROUNDS = 3
|
|
579
|
+
/**
|
|
580
|
+
* 高度差连续多少轮一模一样(且不为 0)即判「卡在同一处」。
|
|
581
|
+
* 门禁要求 heightDelta=0,而实测两次建卡回合都出现过「heightDelta 恒定 56、match 在原地打转」——
|
|
582
|
+
* 只盯 match 的判据会一直判「还在动」,模型就继续做像素微调,而那个撑高 56px 的结构问题从没被碰过。
|
|
583
|
+
* 高度一动不动 = 证据链该换了,不该再调像素。
|
|
584
|
+
*/
|
|
585
|
+
const HEIGHT_FROZEN_ROUNDS = 3
|
|
586
|
+
/**
|
|
587
|
+
* 高度差得大到这个程度,冻结才算「结构问题」而不是「最后一轮才收的像素差」。
|
|
588
|
+
* 2px 这种量级可能是收尾一次性对齐掉的;56px 那种只可能是漏了/多了元素或间距累加。
|
|
589
|
+
* 没有这条下限,一条 match 从 79 一路涨到 92 的好轨迹会被误判成高度停滞(实测重放用例就是这种)。
|
|
590
|
+
*/
|
|
591
|
+
const HEIGHT_FROZEN_MIN_PX = 8
|
|
592
|
+
/** match 提升多少才算刷新最好成绩(低于它是噪声,不算进步) */
|
|
593
|
+
const STALL_EPS = 0.5
|
|
594
|
+
/** 本轮比上轮低多少分算「明确变差」:连续变差时必须提示回退 */
|
|
595
|
+
const REGRESS_DELTA = 1
|
|
596
|
+
/**
|
|
597
|
+
* 每缺一条要素折算成多少「等效 match 分」。
|
|
598
|
+
*
|
|
599
|
+
* 判据三次演进(2026-09-21 二次修正,前两版都在真轨迹上翻车):
|
|
600
|
+
* ① 纯 match:把「删节点 / 换成自编占位文案换来 match 上升」记成进步,回退目标也存成那一轮。
|
|
601
|
+
* ② 字典序「要素齐优先于任何像素分」:修好了 ①,却在 helloCard 真轨迹上更糟——
|
|
602
|
+
* r1 要素齐(19/19)但 match 51.71 / heightDelta 800(结构整个塌掉、渲染高 888px),
|
|
603
|
+
* r4 只缺 2 条但 match 89.71 / heightDelta 20。字典序判 r1 最好,于是 r5 起一路「停滞」,
|
|
604
|
+
* 并把回退目标指向那个 51.71 的破烂结构 —— 模型越回退越差。
|
|
605
|
+
* ③ 折中(现行):`score = match − PENALTY × elementsMissing`,同量纲相加。
|
|
606
|
+
* 权重取 6(1 条要素 ≈ 6 分):
|
|
607
|
+
* · 缺 4 条换来 +12 分(92 vs 80)→ 不算进步,保住 ① 的原意(6×4=24 > 12);
|
|
608
|
+
* · 缺 2 条但多 38 分(89.71 vs 51.71)→ 算进步,不再把塌掉的结构当标杆(6×2=12 < 38)。
|
|
609
|
+
*/
|
|
610
|
+
const MISSING_PENALTY = 6
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 最好成绩的排序分:要素缺失折算成扣分,与 match 同量纲。
|
|
614
|
+
* 没有要素报告(非设计稿链路 / 未传 --spec)时就是裸 match。
|
|
615
|
+
*/
|
|
616
|
+
function bestScore(h) {
|
|
617
|
+
if (!h || h.match == null) return null
|
|
618
|
+
const miss = Number.isFinite(h.elementsMissing) ? h.elementsMissing : null
|
|
619
|
+
return miss == null ? h.match : h.match - MISSING_PENALTY * miss
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* a 是否严格优于 b(带 STALL_EPS 噪声门槛)。
|
|
624
|
+
* 只在**两边都有要素报告**时才启用扣分口径;否则退化成纯 match 比较,
|
|
625
|
+
* 避免「有 fidelity 的轮次」与「无 fidelity 的轮次」混比时被凭空扣分。
|
|
626
|
+
*/
|
|
627
|
+
function isBetterThan(a, b) {
|
|
628
|
+
if (!a || a.match == null) return false
|
|
629
|
+
if (!b || b.match == null) return true
|
|
630
|
+
const comparable = Number.isFinite(a.elementsMissing) && Number.isFinite(b.elementsMissing)
|
|
631
|
+
const sa = comparable ? bestScore(a) : a.match
|
|
632
|
+
const sb = comparable ? bestScore(b) : b.match
|
|
633
|
+
return sa > sb + STALL_EPS
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* 内容就绪门禁(S4.9)——**in-process 调用**,不再 shell 出去:
|
|
638
|
+
* 它每轮都跑(挡在 render 前),起进程的冷启成本会把「省一轮 render」的收益吃掉。
|
|
639
|
+
* readiness.mjs 是纯声明性判定,不依赖 Chrome,直接 import 最划算。
|
|
640
|
+
*
|
|
641
|
+
* 失败不阻断调用方:拿不到就返回 null,门禁退化为「无此项」而不是误判未就绪。
|
|
642
|
+
*/
|
|
643
|
+
function runReadiness({ cardId, schemaPath, dataPath, specPath, designPath }) {
|
|
644
|
+
if (!fs.existsSync(schemaPath)) return null
|
|
645
|
+
try {
|
|
646
|
+
return buildReadinessReport({
|
|
647
|
+
cardId,
|
|
648
|
+
schemaPath,
|
|
649
|
+
dataPath,
|
|
650
|
+
specPath: specPath || '',
|
|
651
|
+
designPath: designPath || '',
|
|
652
|
+
})
|
|
653
|
+
} catch {
|
|
654
|
+
return null
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** preflight.mjs = 机械化的 14 项生成前/后检查(项数以 preflight.mjs 自身输出为准)。
|
|
295
659
|
* 此前它没被任何协议阶段点名,模型从来不知道它存在 —— 这里直接并进每轮迭代报告。 */
|
|
296
660
|
function runPreflight({ cardId, specPath }) {
|
|
297
661
|
if (!fs.existsSync(PREFLIGHT_SCRIPT)) return null
|
|
@@ -314,78 +678,501 @@ function runPreflight({ cardId, specPath }) {
|
|
|
314
678
|
}
|
|
315
679
|
}
|
|
316
680
|
|
|
681
|
+
/**
|
|
682
|
+
* 把「match 差多少分」翻译成「差在哪」。
|
|
683
|
+
*
|
|
684
|
+
* 实测(2026-09-21 建卡回合):报告只说「match 79.86、高度差 56」,从不说是哪个区域、
|
|
685
|
+
* 哪个节点差 —— 模型只能整份重写 IR 去试(另一轮实测 16 次全量重写、12.4 万字符,
|
|
686
|
+
* 约占全部输出的一半)。热区与 contributors 本来就都在报告里,缺的只是把结论说出口。
|
|
687
|
+
*
|
|
688
|
+
* 只转述报告里已有的事实,不新造推断:热区坐标来自 diffHotspots,节点来自 attributeContributors。
|
|
689
|
+
*/
|
|
690
|
+
function describeMatchGap(r) {
|
|
691
|
+
const pd = r.pixelDiff || {}
|
|
692
|
+
const gap = Number((GATE.match - (pd.match ?? 0)).toFixed(2))
|
|
693
|
+
const parts = []
|
|
694
|
+
const hs = (pd.hotspots || []).slice(0, 3).map((h) => `(${h.x},${h.y}) ${h.w}×${h.h}`)
|
|
695
|
+
if (hs.length) parts.push(`最大差异区 ${hs.length} 处:${hs.join(' / ')}`)
|
|
696
|
+
const nodes = []
|
|
697
|
+
for (const c of (Array.isArray(r.contributors) ? r.contributors : []).slice(0, 2)) {
|
|
698
|
+
const el = (c.elements || [])[0]
|
|
699
|
+
if (!el) continue
|
|
700
|
+
const label = el.text ? `text="${String(el.text).slice(0, 18)}"` : el.cid || el.cls || '?'
|
|
701
|
+
const dH = el.dH != null ? ` 高差 ${el.dH > 0 ? '+' : ''}${el.dH}px` : ''
|
|
702
|
+
const flags = (el.flags || []).slice(0, 2).join(' ')
|
|
703
|
+
nodes.push(`${el.cls || '?'} ${label}${dH}${flags ? ` [${flags}]` : ''}`)
|
|
704
|
+
}
|
|
705
|
+
if (nodes.length) parts.push(`最可能的节点:${nodes.join(';')}`)
|
|
706
|
+
if (pd.size && (pd.size.heightDelta || pd.size.widthDelta)) {
|
|
707
|
+
parts.push(`尺寸差 height ${pd.size.heightDelta}px / width ${pd.size.widthDelta}px(门禁要求都为 0)`)
|
|
708
|
+
}
|
|
709
|
+
const co = r.contentOffset1x
|
|
710
|
+
if (co && (co.dx || co.dy)) parts.push(`整体内容偏移 ${co.dx}/${co.dy}px(门禁要求 |dx|,|dy|<=${GATE.contentOffset1x})`)
|
|
711
|
+
if (!parts.length) return null
|
|
712
|
+
return `还差 ${gap} 分。差在这些地方:${parts.join(';')}。一轮只改一处、且先改最内层那个节点,改完立刻重跑 iterate 拿新报告。`
|
|
713
|
+
}
|
|
714
|
+
|
|
317
715
|
/** 门禁判定:全部数值项来自本轮报告,任何一项不达标 passed=false。 */
|
|
318
|
-
function evaluateGate(r) {
|
|
716
|
+
export function evaluateGate(r) {
|
|
319
717
|
const pd = r.pixelDiff || {}
|
|
320
718
|
const co = r.contentOffset1x || null
|
|
321
|
-
const
|
|
719
|
+
const contentBlocked = Boolean(r.readiness && r.readiness.ready === false)
|
|
720
|
+
const checks = []
|
|
721
|
+
// 内容就绪(S4.9)放**最前**:它是所有后续判定的前提 ——
|
|
722
|
+
// 要素没齐时 match 毫无参考价值(实测 gen-1:要素不全却刷到 90.46)。
|
|
723
|
+
if (r.readiness) {
|
|
724
|
+
checks.push({
|
|
725
|
+
name: 'readiness.content',
|
|
726
|
+
value: r.readiness.ready === false ? `未就绪(${r.readiness.stage})` : 'ready',
|
|
727
|
+
required: 'ready(要素齐全 ∧ 变量绑定完备 ∧ 图片地址已配置 ∧ 切图已落盘)',
|
|
728
|
+
ok: r.readiness.ready !== false,
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
// 渲染宽度基准(S4.9 同级前置):宽度错了 match / dy / contentOffset 全部不可信。
|
|
732
|
+
// 历史事故(gen-1):--width 375 跑了前两轮,best 25.5% 是错基准产物,模型从未注意
|
|
733
|
+
// 报告顶层的 widthMismatch 字段 —— 升级为 gate check(structure 阶段)让它出现在 gate.failed 前列。
|
|
734
|
+
if (r.widthMismatch) {
|
|
735
|
+
checks.push({
|
|
736
|
+
name: 'render.widthMismatch',
|
|
737
|
+
value: r.widthMismatch,
|
|
738
|
+
required: '--width 与设计稿卡面宽一致',
|
|
739
|
+
ok: false,
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
checks.push(
|
|
322
743
|
{ name: 'validate.errors', value: r.validate?.errors ?? null, required: '=0', ok: (r.validate?.errors ?? 1) === 0 },
|
|
323
744
|
{ name: 'lint.errors', value: r.lint?.errors ?? null, required: '=0', ok: (r.lint?.errors ?? 1) === 0 },
|
|
324
|
-
|
|
745
|
+
)
|
|
325
746
|
if (r.preflight) checks.push({ name: 'preflight.errors', value: r.preflight.errors, required: '=0', ok: r.preflight.errors === 0 })
|
|
747
|
+
// 要素线(内容真值):设计稿里的文案必须在 schema 里找得到。
|
|
748
|
+
// 放在像素检查之前 —— 要素缺失时先补要素,调像素是白费(改样式不会把丢掉的文案变回来)。
|
|
749
|
+
if (r.fidelity) {
|
|
750
|
+
checks.push({
|
|
751
|
+
name: 'elements.missing',
|
|
752
|
+
value: r.fidelity.missing.length,
|
|
753
|
+
required: '=0',
|
|
754
|
+
ok: r.fidelity.missing.length === 0,
|
|
755
|
+
})
|
|
756
|
+
}
|
|
326
757
|
if (pd.match != null) checks.push({ name: 'pixelDiff.match', value: pd.match, required: `>=${GATE.match}`, ok: pd.match >= GATE.match })
|
|
758
|
+
// 几何隐形项:不做像素对比也能判死 —— 「被压成 0」「跑出卡片」用户必然看不见。
|
|
759
|
+
// 放在像素项之前(结构阶段),避免模型为了刷 match 去动已经正确的结构。
|
|
760
|
+
const geo = r.geometry
|
|
761
|
+
if (geo) {
|
|
762
|
+
checks.push({ name: 'geometry.collapsed', value: geo.counts?.collapsed ?? geo.collapsed?.length ?? 0, required: '=0', ok: (geo.counts?.collapsed ?? geo.collapsed?.length ?? 0) === 0 })
|
|
763
|
+
checks.push({ name: 'geometry.outOfCard', value: geo.counts?.outOfCard ?? geo.outOfCard?.length ?? 0, required: '=0', ok: (geo.counts?.outOfCard ?? geo.outOfCard?.length ?? 0) === 0 })
|
|
764
|
+
}
|
|
327
765
|
if (pd.size) {
|
|
328
766
|
checks.push({ name: 'pixelDiff.size.heightDelta', value: pd.size.heightDelta, required: `=${GATE.heightDelta}`, ok: pd.size.heightDelta === GATE.heightDelta })
|
|
329
767
|
checks.push({ name: 'pixelDiff.size.widthDelta', value: pd.size.widthDelta, required: `=${GATE.widthDelta}`, ok: pd.size.widthDelta === GATE.widthDelta })
|
|
330
768
|
}
|
|
331
769
|
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
770
|
const failed = checks.filter((c) => !c.ok)
|
|
333
|
-
|
|
771
|
+
// 三段式门禁(内容 → 结构 → 像素):先让「内容就绪」达标,再谈结构,最后才谈像素。
|
|
772
|
+
// 历史事故:两段混在一起时,模型会在「为满足 lint 改结构 → match 掉下来 → 改回去」之间震荡几十步;
|
|
773
|
+
// 而更贵的一次是内容没齐就开始刷像素(gen-1:623 万 token 报废)。
|
|
774
|
+
const structureOk = !failed.some((c) => c.name !== 'pixelDiff.match' && !c.name.startsWith('pixelDiff') && c.name !== 'contentOffset1x' && c.name !== 'readiness.content')
|
|
775
|
+
const gate = {
|
|
334
776
|
passed: failed.length === 0,
|
|
777
|
+
stage: failed.length === 0 ? 'pass' : contentBlocked ? 'content' : structureOk ? 'pixel' : 'structure',
|
|
335
778
|
checks,
|
|
336
779
|
failed: failed.map((c) => `${c.name}=${c.value}(要求 ${c.required})`),
|
|
337
780
|
verdict: failed.length === 0 ? 'PASS' : `FAIL:${failed.map((c) => `${c.name}=${c.value}`).join(',')}`,
|
|
338
781
|
}
|
|
782
|
+
// 结构性缺口提示:存在整片缺失带时,把「先补结构」顶到 hint 首位 ——
|
|
783
|
+
// 局部微调线索(contributors/hotspots)在整块漏建面前全是噪音(gen-1 实测 8 轮无效微调后熔断)。
|
|
784
|
+
const gaps = r.pixelDiff?.structuralGaps || []
|
|
785
|
+
if (gaps.length && typeof r.pixelDiff?.match === 'number' && r.pixelDiff.match < GATE.match) {
|
|
786
|
+
gate.structuralHint =
|
|
787
|
+
`渲染存在 ${gaps.length} 片逐行几乎全异的连续带区(1x 卡内 y/h):` +
|
|
788
|
+
gaps.map((g) => `y=${g.y}~${g.y + g.h}(高 ${g.h}pt,设计稿该带中心有墨比例 ${g.designInkPct}%)`).join(';') +
|
|
789
|
+
` —— 要么整块内容漏建(图表 / 分区 / 列表段),要么背景/底色整层错涂。对照 spec_audit 的 bands[] 先修结构,` +
|
|
790
|
+
`match<${GATE.match} 且缺口仍在时忽略 contributors 的局部微调线索。`
|
|
791
|
+
}
|
|
792
|
+
// 内容未就绪:把 blockers 全文写进 verdict,并**明确写出「本轮没跑 render」** ——
|
|
793
|
+
// 不说清楚的话模型会以为「报告里没像素分 = 迭代器坏了」,转而反复重跑空转。
|
|
794
|
+
if (contentBlocked) {
|
|
795
|
+
gate.blocked = true
|
|
796
|
+
gate.blockedBy = 'readiness'
|
|
797
|
+
gate.stage = 'content'
|
|
798
|
+
gate.readiness = { ready: false, stage: r.readiness.stage, blockers: r.readiness.blockers }
|
|
799
|
+
gate.verdict =
|
|
800
|
+
`BLOCKED(内容未就绪,stage=${r.readiness.stage}):本轮**没有 render、没有 pixelDiff,也不消耗迭代轮次**。` +
|
|
801
|
+
`阻塞项 ${r.readiness.blockers.length} 条:\n` +
|
|
802
|
+
r.readiness.blockers.map((b) => ` · ${b}`).join('\n') +
|
|
803
|
+
(r.readiness.hint ? `\n修法:${r.readiness.hint}` : '') +
|
|
804
|
+
'\n补齐内容后再跑 iterate;内容没齐时的像素分没有参考价值,刷它纯属烧轮次。'
|
|
805
|
+
} else if (r.readiness && r.readiness.checks?.slices?.unreferenced?.length) {
|
|
806
|
+
// 设计稿声明了切图但 schema 里没有同名引用 —— 只提示不拦(名字匹配不可靠,见 readiness.mjs 头部说明)
|
|
807
|
+
gate.sliceHints = r.readiness.checks.slices.unreferenced
|
|
808
|
+
}
|
|
809
|
+
if (r.fidelity && r.fidelity.missing.length) {
|
|
810
|
+
gate.verdict +=
|
|
811
|
+
`;要素缺失 ${r.fidelity.missing.length} 条(设计稿有、schema 里找不到):${r.fidelity.missing.slice(0, 5).map((t) => `「${t}」`).join('、')}` +
|
|
812
|
+
' —— **先补要素再调像素**:把丢掉的文案补回对应节点(或把绑定变量的 defaultValue 改成设计稿真值),补完重跑一轮再判门禁。' +
|
|
813
|
+
'注意别用占位文案顶替:schema 里出现设计稿没有的文案同样算要素错误。'
|
|
814
|
+
}
|
|
815
|
+
if (r.fidelity && r.fidelity.invented && r.fidelity.invented.length) {
|
|
816
|
+
gate.invented = r.fidelity.invented.slice(0, 10)
|
|
817
|
+
}
|
|
818
|
+
// 非文本要素缺口(图标 / 分割线):warning 级提示,不判红 —— symbol 可走 slice 位图实现、
|
|
819
|
+
// shape 可能是背景装饰,数量对账有结构性误差(无金标校准);但必须每轮回喂给模型自查,
|
|
820
|
+
// 否则「图标漏建 / 分割线用 Row+背景色模拟」依旧静默(2026-09-25 用户实测抱怨点)。
|
|
821
|
+
const symGap = r.fidelity?.symbols?.gap || 0
|
|
822
|
+
const divGap = r.fidelity?.shapes?.dividerGap || 0
|
|
823
|
+
if (symGap || divGap) {
|
|
824
|
+
gate.elementHints = []
|
|
825
|
+
if (symGap) gate.elementHints.push(`图标:设计稿 symbol ${r.fidelity.symbols.spec} 个 vs schema Icon ${r.fidelity.symbols.schemaIcon} 个(缺 ${symGap})—— 逐个核对图标是否都有 Icon 组件或 slice 落点`)
|
|
826
|
+
if (divGap) gate.elementHints.push(`分割线:设计稿细长条 shape ${r.fidelity.shapes.specDividerish} 条 vs schema Divider ${r.fidelity.shapes.schemaDivider} 个(缺 ${divGap})—— 分割线必须用 Divider 组件(R-DIVIDER),禁止 Row+背景色模拟`)
|
|
827
|
+
}
|
|
828
|
+
// 几何隐形项:把具体节点写进 verdict —— 只说「collapsed=2」等于让模型自己找是哪两个。
|
|
829
|
+
if (geo && (geo.collapsed?.length || geo.outOfCard?.length)) {
|
|
830
|
+
gate.geometry = { collapsed: geo.collapsed, outOfCard: geo.outOfCard, overWrapped: geo.overWrapped }
|
|
831
|
+
const detail = describeGeometry(geo)
|
|
832
|
+
const fix =
|
|
833
|
+
' 修法:这些节点全在布局里,**别动视觉样式**——按 cid 找到那个节点,' +
|
|
834
|
+
'① 被压成 0 宽:说明同排有个容器把整行吃光了(横向流里的容器若不写 rect.width,渲染器按默认「100%」出),' +
|
|
835
|
+
'给那两者按设计稿补显式 rect.width(itemValueFrom:"0");确实要占满剩余空间就用 flexGrow:1 + flexShrink:1。' +
|
|
836
|
+
'② 跑出卡片右缘:同上,横向兄弟宽度之和超了卡片宽(卡内宽 = 卡宽 − 左右 padding),按设计稿重新分配宽度。'
|
|
837
|
+
gate.geometryDetail = detail
|
|
838
|
+
gate.verdict = `${gate.verdict};${detail}。${fix}`
|
|
839
|
+
}
|
|
840
|
+
// 像素类不达标时补一句「差在哪」:只报分数等于把「找哪里差」整段外包给模型的猜测试错。
|
|
841
|
+
if (failed.some((c) => c.name.startsWith('pixelDiff') || c.name === 'contentOffset1x')) {
|
|
842
|
+
const gapDetail = describeMatchGap(r)
|
|
843
|
+
if (gapDetail) {
|
|
844
|
+
gate.gapDetail = gapDetail
|
|
845
|
+
gate.verdict = `${gate.verdict};${gapDetail}`
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// validate 没跑起来(机器上没有可用 python)⇒ 谁都修不好这一项:继续 --set 只是烧步数。
|
|
849
|
+
// 与普通 FAIL 区分开,调用方(模型)照 verdict 停手 + 告知用户,而不是接着盲调。
|
|
850
|
+
if (r.validate?.envError === true) {
|
|
851
|
+
gate.passed = false
|
|
852
|
+
gate.blockedBy = 'validate-no-python'
|
|
853
|
+
gate.envBlocked = 'validate 没跑起来:本机没有可用的 python 解释器'
|
|
854
|
+
gate.checks.push({ name: 'validate.runnable', value: 'no-python', required: '能用 python 跑 validate_schema.py', ok: false })
|
|
855
|
+
gate.verdict =
|
|
856
|
+
'BLOCKED(环境问题,不是卡片的错):validate 没跑起来,改 schema / 继续 --set 都修不好它。' +
|
|
857
|
+
'停手,如实告诉用户「装 Python 或设 DSL_CARD_PYTHON=<解释器路径>」后重跑 iterate;' +
|
|
858
|
+
`其余检查:${failed.length ? failed.map((c) => `${c.name}=${c.value}`).join(',') : '全部达标'}`
|
|
859
|
+
}
|
|
860
|
+
return gate
|
|
339
861
|
}
|
|
340
862
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
863
|
+
/**
|
|
864
|
+
* IR 轮次快照:每次 iterate 落一份 `<id>.ir.rNN.json`,刷新最好成绩时另存 `<id>.ir.best.json`。
|
|
865
|
+
* 以前没有任何历史版本 ⇒ 发现「越改越差」时唯一的办法是凭记忆全量重写整份 IR
|
|
866
|
+
* (实测 16 次 / 12.4 万字符 ≈ 全部输出的一半)。有快照,回退才是可执行的下一步。
|
|
867
|
+
*/
|
|
868
|
+
function snapshotIR(cardId, irPath, round) {
|
|
869
|
+
if (!cardId || !irPath || !fs.existsSync(irPath)) return null
|
|
870
|
+
const name = `${cardId}.ir.r${String(round).padStart(2, '0')}.json`
|
|
871
|
+
try {
|
|
872
|
+
fs.copyFileSync(irPath, path.join(SCRATCH_DIR, name))
|
|
873
|
+
return { name, path: path.join(SCRATCH_DIR, name) }
|
|
874
|
+
} catch {
|
|
875
|
+
return null
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** 停滞提示:只给可执行的下一步(回退 → 单分项 → 换证据链),不再罗列一堆可选分析。 */
|
|
880
|
+
/** 高度差停滞提示:问题在「结构撑高」而不是「像素对齐」,别让它继续调像素。 */
|
|
881
|
+
function heightStalledBlock({ cardId, cur }) {
|
|
882
|
+
const hd = cur?.heightDelta
|
|
883
|
+
return [
|
|
884
|
+
`⚠ 高度差已连续 ${HEIGHT_FROZEN_ROUNDS} 轮一动不动(heightDelta=${hd},门禁要求 0):**问题在结构、不在像素**。`,
|
|
885
|
+
'再调 match 也过不了门禁——先把高度这 ${hd}px 定位出来(逐带 dy/dh 会指出是哪一段把总高撑出去的:通常是一处被漏掉/多出的元素,或某层 margin/padding 累加),只改那一处结构(补元素 / 删元素 / 改间距),改完立刻重跑 iterate 拿新的 heightDelta 做判据。',
|
|
886
|
+
].join('\n')
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function stalledHint({ cardId, roundsSinceBest, best, cur, last, regressed, gate, irRel, reason }) {
|
|
890
|
+
// gate.failed 在 evaluateGate 里**已经是格式化好的字符串数组**(`name=value(要求 X)`),
|
|
891
|
+
// 这里再按对象取 c.name/c.value 会渲染成 `undefined=undefined(要求 undefined)`——
|
|
892
|
+
// 模型读到的是乱码建议(实测 r14 报告)。直接切片拼接即可。
|
|
893
|
+
const failed = (gate?.failed || []).slice(0, 2)
|
|
894
|
+
// 结构阶段(内容未就绪 / 无像素对比)没有 match 可比,别渲染成「本轮 undefined%」。
|
|
895
|
+
const fmtScore = (v) => (Number.isFinite(v) ? `${v}%` : '(本轮无像素分)')
|
|
896
|
+
// 回退命令里的 `--ir` 必须是**本次跑的那份工作 IR**(persistReport 手里就有),不能猜路径:
|
|
897
|
+
// 模型把 IR 放哪儿由它自己决定(实测有 UI/_gen/<draft>/、.cache/ai-scratch/ 等多种落点),
|
|
898
|
+
// 猜错的结果是回退命令直接报「工作 IR 不存在」,白烧一步。
|
|
899
|
+
const irTarget = irRel || `.cache/ai-scratch/${cardId}.ir.json`
|
|
900
|
+
const lines = [
|
|
901
|
+
`已连续 ${roundsSinceBest} 轮没有刷新最好成绩:最好 ${fmtScore(best.match)} @ r${best.round},本轮 ${fmtScore(cur.match)}${regressed && last ? `(比上轮 ${fmtScore(last.match)} 还低)` : ''}。`,
|
|
902
|
+
]
|
|
903
|
+
// 要素下滑单独点名:这类轮次**不该**继续调像素——像素分可能是靠删要素/换占位文案换来的。
|
|
904
|
+
if (cur.elementsMissing != null && best.elementsMissing != null && cur.elementsMissing > best.elementsMissing) {
|
|
905
|
+
lines.push(
|
|
906
|
+
`⚠ 本轮的要素比最好成绩还少 ${cur.elementsMissing - best.elementsMissing} 条(最好 r${best.round} 缺 ${best.elementsMissing} 条 / 本轮缺 ${cur.elementsMissing} 条):**这是在删要素凑像素**。先把要素补回来(设计稿有、schema 里没有的文案见报告 elements.missing / gate.verdict),再谈 match。`,
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
lines.push(
|
|
910
|
+
'--set 微调已到天花板,禁止继续盲调、禁止再全量重写 IR。按序做:',
|
|
911
|
+
`① 回退到最好结构:skill_exec script=skills/dsl-card-generator/scripts/ir_restore.mjs args=["--best",".cache/ai-scratch/${cardId}.ir.best.json","--ir","${irTarget}"] —— 回退后必须重跑一轮 iterate 拿新报告,不要拿旧报告当门禁。`,
|
|
912
|
+
failed.length
|
|
913
|
+
? `② 只修一个分项:本轮 gate.failed 前两项是 ${failed.join(' / ')}。一轮只改一项,改完立刻重跑 iterate(同时改多项无法归因)。`
|
|
914
|
+
: '② 只修一个分项:按本轮 gate.failed 的第一项,一轮只改一项,改完立刻重跑 iterate。',
|
|
915
|
+
`③ 同一项连改两轮都不动 ⇒ 换证据链:skill_exec script=skills/dsl-card-generator/scripts/diff_bands.mjs args=["--id","${cardId}","--design","<设计稿>","--render","<本轮 renderImage>"] 拿逐带 dy/dh 与分区主色,再对照 references/generation_pitfalls.md 的「类型开关+值 / 静默丢弃」清单。`,
|
|
916
|
+
)
|
|
917
|
+
// 高度线单独触发时,先插一段「问题在结构、不在像素」的定向说明,再照常给回退与单分项建议
|
|
918
|
+
//(回退命令在两种停滞形态下都有用,不该因为换了判据就被吞掉)。
|
|
919
|
+
if (reason === 'height') lines.unshift(heightStalledBlock({ cardId, cur }))
|
|
920
|
+
return lines.join('\n')
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* 迭代身份:**同一张卡换设计稿整卡重做**时必须把轮次历史与最好成绩全部作废。
|
|
925
|
+
*
|
|
926
|
+
* 为什么要(2026-09-24 实机事故):持久化只按 cardId 认,`gen-1` 先按「理财卡」设计稿
|
|
927
|
+
* 迭代到 r17(best 91.64% @ r6 / 19 条要素),用户随后把同一张卡换成「收支分析」设计稿
|
|
928
|
+
* 重做 —— 新设计的第一轮 iterate 直接被记成 r18,撞上旧设计的 best:
|
|
929
|
+
* `score = 80.15 − 6×8 缺要素` 远不如 91.64 ⇒ roundsSinceBest = 18 − 6 = 12 ≥ STALL_ROUNDS
|
|
930
|
+
* ⇒ 当场判 stalled ⇒ ai-chat 的还原度熔断随即禁用 iterate / ir_patch,模型只能收尾交一张
|
|
931
|
+
* **stage=structure、缺 8 条要素、match 80%、高差 −76px** 的卡。整轮 96 万 prompt token 报废。
|
|
932
|
+
*
|
|
933
|
+
* 判据只用设计稿绝对路径:它换新就是另一张卡的内容真值,旧历史对像素/要素两条线都没有参照意义;
|
|
934
|
+
* 同一设计稿重跑则保留历史(`ir_restore --best` 仍可用,别把回退能力一起废掉)。
|
|
935
|
+
* 显式重置用 `--reset`(换设计稿但还是想从零计时)。
|
|
936
|
+
*/
|
|
937
|
+
function iterationIdentity(designPath, irPath) {
|
|
938
|
+
if (designPath) {
|
|
939
|
+
try {
|
|
940
|
+
return `design:${fs.realpathSync(path.resolve(designPath))}`
|
|
941
|
+
} catch {
|
|
942
|
+
return `design:${path.resolve(designPath)}`
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// 没给 --design(纯结构迭代)时退回 IR 自身路径,至少不会跨卡串味
|
|
946
|
+
return `ir:${irPath ? path.resolve(irPath) : ''}`
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** 换设计稿重置:轮次快照与 best 都指向旧设计,留着会误导 ir_restore 把另一张卡的 IR 写回来 */
|
|
950
|
+
function dropStaleSnapshots(cardId) {
|
|
951
|
+
const dropped = []
|
|
952
|
+
let names = []
|
|
953
|
+
try {
|
|
954
|
+
names = fs.readdirSync(SCRATCH_DIR)
|
|
955
|
+
} catch {
|
|
956
|
+
return dropped
|
|
957
|
+
}
|
|
958
|
+
const prefix = `${cardId}.ir.`
|
|
959
|
+
for (const n of names) {
|
|
960
|
+
if (n === `${cardId}.ir.best.json` || /\.ir\.r\d+\.json$/.test(n)) {
|
|
961
|
+
if (!n.startsWith(prefix)) continue
|
|
962
|
+
try {
|
|
963
|
+
fs.unlinkSync(path.join(SCRATCH_DIR, n))
|
|
964
|
+
dropped.push(n)
|
|
965
|
+
} catch {
|
|
966
|
+
/* 删不掉不致命:报告已被清空,最多留几个孤儿文件 */
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return dropped
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** 报告落盘 + 轮次历史 + 停滞检测(连续多轮没刷新最好成绩 = 微调到天花板,必须叫停并回退)。 */
|
|
974
|
+
export function persistReport(result, { sp, dp, irPath, reset = false, design = '' }) {
|
|
343
975
|
try {
|
|
344
976
|
fs.mkdirSync(SCRATCH_DIR, { recursive: true })
|
|
345
977
|
const reportPath = path.join(SCRATCH_DIR, `${result.cardId}.iterate.json`)
|
|
978
|
+
const bestIrPath = path.join(SCRATCH_DIR, `${result.cardId}.ir.best.json`)
|
|
346
979
|
let prev = null
|
|
347
980
|
try {
|
|
348
981
|
prev = JSON.parse(fs.readFileSync(reportPath, 'utf8'))
|
|
349
982
|
} catch {}
|
|
983
|
+
// 设计稿换了(或显式 --reset)⇒ 轮次历史 / best / 快照全部作废,本轮记 r1。
|
|
984
|
+
// 判据见 iterationIdentity:只按 cardId 认会让新设计第 1 轮撞上旧设计的 best 而被误判停滞。
|
|
985
|
+
const identity = iterationIdentity(design, irPath)
|
|
986
|
+
let resetReason = ''
|
|
987
|
+
if (prev) {
|
|
988
|
+
if (reset) resetReason = '显式 --reset'
|
|
989
|
+
// 旧报告(本判据上线前落的)没有 identity,无从比对 ⇒ 按「无法证明是同一设计稿」处理,
|
|
990
|
+
// 一次性重置。代价只有「丢一次回退目标」,而误判停滞的代价是整轮生成报废(见上面注释)。
|
|
991
|
+
else if (!prev.identity) resetReason = '旧报告无迭代身份(判据上线前落的),无法证明与当前设计稿同一'
|
|
992
|
+
else if (prev.identity !== identity) resetReason = `设计稿已更换(${prev.identity} → ${identity})`
|
|
993
|
+
}
|
|
994
|
+
const droppedSnapshots = resetReason ? dropStaleSnapshots(result.cardId) : []
|
|
995
|
+
if (resetReason) prev = null
|
|
350
996
|
const pd = result.pixelDiff || {}
|
|
351
997
|
const hotSig = (pd.hotspots || []).slice(0, 3).map((h) => `${h.x},${h.y}`).join('|')
|
|
352
998
|
const history = Array.isArray(prev?.history) ? prev.history.slice(-9) : []
|
|
353
999
|
const last = history[history.length - 1]
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1000
|
+
|
|
1001
|
+
/* ── 内容未就绪:**不消耗迭代轮次** ────────────────────────────────
|
|
1002
|
+
* 本轮根本没跑 render,谈「第几轮迭代」没有意义;更关键的是——
|
|
1003
|
+
* 若照常 push 一条 history,那么模型「改内容 → 被拦一次 → 又改 → 又被拦」会白白把
|
|
1004
|
+
* 历史窗口(只留 9 条)挤满、并把 `round` 抬高,进而污染 `roundsSinceBest` 与停滞判据
|
|
1005
|
+
* (实测 gen-1 的 r12/r13 就是这种「什么都没变却各占一轮」的幽灵轮次)。
|
|
1006
|
+
* 所以这里只刷新 gate / blockedAt,history 与 best 原样保留,不写 IR 快照。 */
|
|
1007
|
+
if (result.readiness && result.readiness.ready === false) {
|
|
1008
|
+
const blockedReport = {
|
|
1009
|
+
...result,
|
|
1010
|
+
elements: undefined,
|
|
1011
|
+
identity,
|
|
1012
|
+
schemaHash: hashFile(sp),
|
|
1013
|
+
dataHash: hashFile(dp),
|
|
1014
|
+
history,
|
|
1015
|
+
best: prev?.best,
|
|
1016
|
+
round: history.length,
|
|
1017
|
+
stalled: undefined,
|
|
1018
|
+
blockedAt: {
|
|
1019
|
+
ts: Date.now(),
|
|
1020
|
+
stage: result.readiness.stage,
|
|
1021
|
+
blockers: result.readiness.blockers,
|
|
1022
|
+
hint: result.readiness.hint || null,
|
|
1023
|
+
},
|
|
1024
|
+
}
|
|
1025
|
+
blockedReport.gate = evaluateGate(blockedReport)
|
|
1026
|
+
blockedReport.conclusion = 'blocked-content'
|
|
1027
|
+
fs.writeFileSync(reportPath, JSON.stringify(blockedReport, null, 2) + '\n')
|
|
1028
|
+
return {
|
|
1029
|
+
reportPath,
|
|
1030
|
+
stalled: null,
|
|
1031
|
+
gate: blockedReport.gate,
|
|
1032
|
+
conclusion: 'blocked-content',
|
|
1033
|
+
rounds: history.length,
|
|
1034
|
+
blocked: true,
|
|
1035
|
+
}
|
|
359
1036
|
}
|
|
360
|
-
|
|
1037
|
+
|
|
1038
|
+
const round = (last?.round || history.length) + 1
|
|
1039
|
+
const snapshot = snapshotIR(result.cardId, irPath, round)
|
|
1040
|
+
// 历史最好成绩(不含本轮):停滞判据的基准。
|
|
1041
|
+
// 必须读上一份报告里**单独持久化**的 best —— history 只留最近 9 条,
|
|
1042
|
+
// 最好成绩一旦被窗口挤出(实测 r10 的最好成绩到 r20 就没了),判据就会误判成「刚刷新过」。
|
|
1043
|
+
const prevBest = prev?.best && Number.isFinite(prev.best.match) ? prev.best : null
|
|
1044
|
+
// 注意:这里必须用 bestScore(= match − 6×缺要素)而不是裸 match,否则
|
|
1045
|
+
// 「基准」与下面 isBetterThan 的口径不一致 —— 实测会出现「基准选了个 51.71 的塌陷轮次,
|
|
1046
|
+
// 然后拿它当回退目标」的错位。
|
|
1047
|
+
const bestBefore = [prevBest, ...history]
|
|
1048
|
+
.filter((h) => h && h.match != null)
|
|
1049
|
+
.reduce((a, h) => (a == null || bestScore(h) > bestScore(a) ? h : a), null)
|
|
1050
|
+
// 要素线(内容真值):设计稿的文案是固定要素,迭代只许改样式布局。
|
|
1051
|
+
// 记进每轮历史,才能看出「越改越少节点/要素」这种下滑(用户实测反馈的形态)。
|
|
1052
|
+
const fid = result.fidelity || null
|
|
1053
|
+
const cur = {
|
|
361
1054
|
ts: Date.now(),
|
|
1055
|
+
round,
|
|
362
1056
|
match: pd.match ?? null,
|
|
1057
|
+
elementsTotal: fid?.total ?? null,
|
|
1058
|
+
elementsMatched: fid?.matched ?? null,
|
|
1059
|
+
elementsMissing: Array.isArray(fid?.missing) ? fid.missing.length : null,
|
|
1060
|
+
elementsInvented: Array.isArray(fid?.invented) ? fid.invented.length : null,
|
|
363
1061
|
heightDelta: pd.size?.heightDelta ?? null,
|
|
364
1062
|
contentOffset1x: result.contentOffset1x || null,
|
|
365
1063
|
lintErrors: result.lint?.errors ?? null,
|
|
366
1064
|
validateErrors: result.validate?.errors ?? null,
|
|
367
1065
|
hotSig,
|
|
368
1066
|
sets: (result.appliedSets || []).map((a) => a.path),
|
|
369
|
-
|
|
1067
|
+
irSnapshot: snapshot?.name || null,
|
|
1068
|
+
}
|
|
1069
|
+
history.push(cur)
|
|
1070
|
+
// 最好成绩的判据:`score = match − 6×缺要素`(同量纲,见 MISSING_PENALTY 注释)。
|
|
1071
|
+
// 为什么不纯比 match:要素是设计稿给定的真值,把真值换成占位文案(或删掉节点)会让 match 反而升——
|
|
1072
|
+
// 实测 2026-09-21 demo 回合「产品名/利率/期限整批换成自编占位值」后像素照样 89%。
|
|
1073
|
+
// 为什么不字典序优先要素:同一回合的 r1(要素齐 / 51.71 / 高差 800)会被判成优于
|
|
1074
|
+
// r4(缺 2 条 / 89.71 / 高差 20),回退目标直接指向塌掉的结构。
|
|
1075
|
+
const improved = bestBefore != null && isBetterThan(cur, bestBefore)
|
|
1076
|
+
const best = bestBefore == null || improved ? cur : bestBefore
|
|
1077
|
+
if (best === cur && irPath && fs.existsSync(irPath)) {
|
|
1078
|
+
try {
|
|
1079
|
+
fs.copyFileSync(irPath, bestIrPath)
|
|
1080
|
+
} catch {}
|
|
1081
|
+
}
|
|
1082
|
+
const roundsSinceBest = best?.round ? round - best.round : 0
|
|
1083
|
+
const regressed = Boolean(last && cur.match != null && last.match != null && cur.match < last.match - REGRESS_DELTA)
|
|
1084
|
+
const stalledByMatch = cur.match != null && best?.match != null && roundsSinceBest >= STALL_ROUNDS
|
|
1085
|
+
// 高度线:连续 HEIGHT_FROZEN_ROUNDS 轮 heightDelta 一模一样且不为 0。
|
|
1086
|
+
// 只在门禁确实卡在高度上时才升格为停滞(heightDelta 已经在别的轮次里动过就不算)。
|
|
1087
|
+
const heightSeq = history.map((h) => h.heightDelta).filter((v) => v != null)
|
|
1088
|
+
const heightFrozen =
|
|
1089
|
+
heightSeq.length >= HEIGHT_FROZEN_ROUNDS &&
|
|
1090
|
+
Math.abs(heightSeq[heightSeq.length - 1]) >= HEIGHT_FROZEN_MIN_PX &&
|
|
1091
|
+
heightSeq.slice(-HEIGHT_FROZEN_ROUNDS).every((v) => v === heightSeq[heightSeq.length - 1])
|
|
1092
|
+
// 两条线各自独立触发:match 不刷新最好成绩,或高度差显著地冻住不动。
|
|
1093
|
+
// 后者单独判停是因为门禁要求 heightDelta=0 —— 高度冻着不动时,再调像素也永远过不了。
|
|
1094
|
+
const stalledBy = stalledByMatch ? 'match' : heightFrozen ? 'height' : null
|
|
1095
|
+
const stalled = Boolean(stalledBy)
|
|
370
1096
|
const persisted = {
|
|
371
1097
|
...result,
|
|
372
1098
|
elements: undefined,
|
|
1099
|
+
// 迭代身份:下一轮据此判「还是同一张内容真值吗」,换了就重置(见 iterationIdentity)
|
|
1100
|
+
identity,
|
|
373
1101
|
schemaHash: hashFile(sp),
|
|
374
1102
|
dataHash: hashFile(dp),
|
|
375
1103
|
history,
|
|
376
|
-
|
|
1104
|
+
best: best
|
|
1105
|
+
? {
|
|
1106
|
+
match: best.match,
|
|
1107
|
+
// 排序分一起落盘:下一轮据此比「谁才是真正的最好成绩」,与 isBetterThan 同口径
|
|
1108
|
+
score: bestScore(best),
|
|
1109
|
+
round: best.round,
|
|
1110
|
+
// 要素线一起落盘:下一轮据此判「这轮是不是靠丢要素换的像素分」
|
|
1111
|
+
elementsMissing: best.elementsMissing ?? null,
|
|
1112
|
+
elementsMatched: best.elementsMatched ?? null,
|
|
1113
|
+
irSnapshot: best.irSnapshot || null,
|
|
1114
|
+
ts: best.ts || null,
|
|
1115
|
+
}
|
|
1116
|
+
: undefined,
|
|
1117
|
+
stalled: undefined,
|
|
377
1118
|
}
|
|
378
1119
|
persisted.gate = evaluateGate(persisted)
|
|
1120
|
+
if (stalled) {
|
|
1121
|
+
persisted.stalled = {
|
|
1122
|
+
reason: stalledBy,
|
|
1123
|
+
rounds: roundsSinceBest,
|
|
1124
|
+
best: best
|
|
1125
|
+
? { match: best.match, round: best.round, elementsMissing: best.elementsMissing ?? null, irSnapshot: best.irSnapshot }
|
|
1126
|
+
: null,
|
|
1127
|
+
regressed,
|
|
1128
|
+
bestIr: fs.existsSync(bestIrPath) ? bestIrPath : null,
|
|
1129
|
+
hint: stalledHint({
|
|
1130
|
+
cardId: result.cardId,
|
|
1131
|
+
roundsSinceBest,
|
|
1132
|
+
best,
|
|
1133
|
+
cur,
|
|
1134
|
+
last,
|
|
1135
|
+
regressed,
|
|
1136
|
+
gate: persisted.gate,
|
|
1137
|
+
irRel: irPath ? path.relative(repoRoot, path.resolve(irPath)) : '',
|
|
1138
|
+
reason: stalledBy,
|
|
1139
|
+
}),
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// 显式落结论:一张卡目前处于哪种状态,必须一眼可读。
|
|
1143
|
+
// 旧版只有 gate.passed 一个布尔——「没过但也没判停滞」时,模型可以直接停手、界面上
|
|
1144
|
+
// 却看不出这卡是「还在迭代」还是「被放弃了」(实测 2 轮后无声转向下一张卡)。
|
|
1145
|
+
persisted.conclusion = persisted.gate.passed ? 'pass' : stalledBy ? 'stalled' : 'iterating'
|
|
379
1146
|
fs.writeFileSync(reportPath, JSON.stringify(persisted, null, 2) + '\n')
|
|
380
|
-
|
|
1147
|
+
// 轮次快照只留最近 12 轮 + best:这是个「每轮一份 IR 全文」的目录,长任务下会无限堆积
|
|
1148
|
+
const pruned = pruneSnapshots(result.cardId)
|
|
1149
|
+
return {
|
|
1150
|
+
reportPath,
|
|
1151
|
+
stalled: persisted.stalled,
|
|
1152
|
+
gate: persisted.gate,
|
|
1153
|
+
conclusion: persisted.conclusion,
|
|
1154
|
+
rounds: history.length,
|
|
1155
|
+
pruned,
|
|
1156
|
+
// 重置必须显式回喂:模型看到「r1」才知道前面的历史已作废,否则会拿旧报告里的
|
|
1157
|
+
// match / best 当参照继续谈收敛。
|
|
1158
|
+
iterationReset: resetReason
|
|
1159
|
+
? { reason: resetReason, droppedSnapshots: droppedSnapshots.length, round: persisted.history?.[0]?.round ?? 1 }
|
|
1160
|
+
: undefined,
|
|
1161
|
+
}
|
|
381
1162
|
} catch {
|
|
382
1163
|
return null
|
|
383
1164
|
}
|
|
384
1165
|
}
|
|
385
1166
|
|
|
386
1167
|
/* ── 核心管线 ──────────────────────────────────────────────── */
|
|
387
|
-
async function runPipeline({ ir: irPath, out: outDir, design: designPath, width, session, alwaysDiffImage, withElements, sets, specPath, skipPreflight, slicesDir: slicesDirArg }) {
|
|
1168
|
+
async function runPipeline({ ir: irPath, out: outDir, design: designPath, width, session, alwaysDiffImage, withElements, sets, specPath, skipPreflight, slicesDir: slicesDirArg, reset: resetFlag, noReadiness: noReadinessArg }) {
|
|
388
1169
|
const t0 = performance.now()
|
|
1170
|
+
/** 内容就绪门禁开关:--no-readiness 或环境变量(debug 用逃生门) */
|
|
1171
|
+
const noReadiness = Boolean(noReadinessArg) || process.env.DSL_CARD_SKIP_READINESS === '1'
|
|
1172
|
+
|
|
1173
|
+
// 设计稿侧元素(供 contributors 的 designH/deltaH):只有给了 --spec 才可用。
|
|
1174
|
+
// 拿不到就不给 deltaH —— 宁缺勿滥,编不出可证伪的数字比没有数字更坏。
|
|
1175
|
+
const designSide = loadDesignElements(specPath, designPath)
|
|
389
1176
|
|
|
390
1177
|
// 1. Read IR(可选 --set 编辑,原子写回)→ expand
|
|
391
1178
|
let ir
|
|
@@ -434,17 +1221,71 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
|
|
|
434
1221
|
const errs = Array.isArray(v.errors) ? v.errors : []
|
|
435
1222
|
const warns = Array.isArray(v.warnings) ? v.warnings : []
|
|
436
1223
|
validateReport = { ok: !!v.ok, errors: errs.length, warnings: warns.length, details: errs.slice(0, 5) }
|
|
1224
|
+
// 没找到可用 python ⇒ validate 根本没跑。带着 envError 进报告,门禁据此判「环境阻塞」,
|
|
1225
|
+
// 不让模型把「装 Python 才能解决的事」当成「再调几轮就能过」的门禁缺口。
|
|
1226
|
+
if (v.envError) validateReport.envError = true
|
|
437
1227
|
} catch (e) {
|
|
438
1228
|
validateReport = { ok: false, errors: 1, warnings: 0, details: [`validate 执行异常: ${e.message || e}`] }
|
|
439
1229
|
}
|
|
440
1230
|
|
|
1231
|
+
// 3.9 ★ 内容就绪门禁(S4.9)——**挡在最贵的 render + pixelDiff 之前**。
|
|
1232
|
+
//
|
|
1233
|
+
// 2026-09-24 gen-1 事故(用户原话):「只有在确保内容和要素的齐全和准备再做像素自循环,
|
|
1234
|
+
// 不然跟个傻逼一样浪费时间在像素循环里打转。这次卡片连基本的切图引入跑了半天都没为我添加上去」。
|
|
1235
|
+
// 实测:两个回合 623 万 prompt token 全废;iterate 历史 r4–r13 十轮 match 卡在 87–90.46,
|
|
1236
|
+
// `gate.stage` 全程 = 'pixel'(结构早过了),而 Image 的图片地址因属性键写成 `image`
|
|
1237
|
+
// 被运行时静默丢弃、渲染成破图 —— **要素不全却已经在刷像素**。
|
|
1238
|
+
//
|
|
1239
|
+
// 门禁项 A 组件契约 / B 要素齐全 / C 变量绑定完备 / D 图片地址已配置 / E 切图路径已配置,
|
|
1240
|
+
// 全是**声明性判定**(不渲染即可判),所以放在这里:不合格就直接 return,
|
|
1241
|
+
// 一轮 render(≈486ms Chrome) + pixelDiff(全量 84 万像素) + 归因全部省掉。
|
|
1242
|
+
//
|
|
1243
|
+
// 判据选型(全仓 75 张卡实测):71/75 ready,4 条未就绪**全是真实的 r31c 悬空引用**,0 误报。
|
|
1244
|
+
const readiness = noReadiness ? null : runReadiness({ cardId, schemaPath: sp, dataPath: dp, specPath, designPath })
|
|
1245
|
+
if (readiness && readiness.ready === false) {
|
|
1246
|
+
const gateBlocked = evaluateGate({ validate: validateReport, lint: { errors: lintErrors.length }, readiness })
|
|
1247
|
+
const result = {
|
|
1248
|
+
ok: true,
|
|
1249
|
+
cardId,
|
|
1250
|
+
blocked: 'content',
|
|
1251
|
+
readiness: { ready: false, stage: readiness.stage, blockers: readiness.blockers, hint: readiness.hint, checks: readiness.checks },
|
|
1252
|
+
validate: validateReport,
|
|
1253
|
+
lint: { errors: lintErrors.length, warnings: lintWarns.length, infos: lintInfos.length },
|
|
1254
|
+
gate: gateBlocked,
|
|
1255
|
+
conclusion: 'blocked-content',
|
|
1256
|
+
readinessNote:
|
|
1257
|
+
'内容未就绪 ⇒ 本轮**没有** render、**没有** pixelDiff、**不消耗迭代轮次**(不写快照、不进 history)。' +
|
|
1258
|
+
'先按 blockers 把内容补齐(要素 / 变量绑定 / 图片地址 / 切图落盘),补完再跑 iterate。' +
|
|
1259
|
+
'这一步的存在意义就是:内容没齐时像素分毫无参考价值,刷它纯属烧轮次。',
|
|
1260
|
+
elapsedMs: Math.round(performance.now() - t0),
|
|
1261
|
+
}
|
|
1262
|
+
const saved = persistReport(result, { sp, dp, irPath, reset: resetFlag, design: designPath })
|
|
1263
|
+
if (saved) {
|
|
1264
|
+
result.report = saved.reportPath
|
|
1265
|
+
result.rounds = saved.rounds
|
|
1266
|
+
result.gate = saved.gate
|
|
1267
|
+
result.conclusion = saved.conclusion
|
|
1268
|
+
}
|
|
1269
|
+
return result
|
|
1270
|
+
}
|
|
1271
|
+
|
|
441
1272
|
// 4. Render (reuse session if provided, else cold start)
|
|
442
|
-
|
|
1273
|
+
// 宽度优先级:显式 --width > 设计稿卡面宽(spec 是 1x,卡面如 343)> schema.view.width > 默认。
|
|
1274
|
+
// 实测事故:第 44 次调用用了默认 375 跑完一轮,第 46 次才改成 343(设计稿 686 = 343×2)—— 白烧一轮。
|
|
1275
|
+
const renderWidth = width || (designSide?.width ? designSide.width : 0) || Number(schema.view?.width) || DEFAULT_WIDTH
|
|
1276
|
+
// 显式宽度与设计稿卡面不符时响亮告警:宽度决定像素对比基准,错了 match 会被整体压低、且逐带 dy 全歪
|
|
1277
|
+
const widthMismatch =
|
|
1278
|
+
width && designSide?.width && Math.abs(width - designSide.width) > 1
|
|
1279
|
+
? `--width ${width} 与设计稿卡面宽 ${designSide.width} 不一致(设计稿坐标是 1x):请改 --width ${designSide.width},否则像素对比基准不对、match 与 dy 都不可信。`
|
|
1280
|
+
: null
|
|
1281
|
+
// 归因需要逐元素度量:有设计稿时默认抓(一次 Runtime.evaluate,几十毫秒),
|
|
1282
|
+
// 相比「没有归因 ⇒ 模型整份重写 IR」的代价可以忽略
|
|
1283
|
+
const captureElements = !!withElements || !!designPath
|
|
443
1284
|
let renderRes
|
|
444
1285
|
if (session) {
|
|
445
|
-
renderRes = await session.render({ id: cardId, width: renderWidth, format: 'png', hotReload: true, captureElements
|
|
1286
|
+
renderRes = await session.render({ id: cardId, width: renderWidth, format: 'png', hotReload: true, captureElements })
|
|
446
1287
|
} else {
|
|
447
|
-
renderRes = await renderCardScreenshot({ id: cardId, width: renderWidth, format: 'png', captureElements
|
|
1288
|
+
renderRes = await renderCardScreenshot({ id: cardId, width: renderWidth, format: 'png', captureElements })
|
|
448
1289
|
}
|
|
449
1290
|
|
|
450
1291
|
if (renderRes.error) {
|
|
@@ -474,6 +1315,8 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
|
|
|
474
1315
|
const designPng = PNG.sync.read(designBuf)
|
|
475
1316
|
const scale = renderWidth ? renderPng.width / renderWidth : 2
|
|
476
1317
|
pixelDiffResult.hotspots = diffHotspots(renderPng, designPng, { scale })
|
|
1318
|
+
// 结构性缺口:整块漏建的区域(高度差的主要实体)。放在报告里供 gate hint 引用。
|
|
1319
|
+
try { pixelDiffResult.structuralGaps = detectStructuralGaps(renderPng, designPng, { scale }) } catch {}
|
|
477
1320
|
} catch {}
|
|
478
1321
|
if (true) {
|
|
479
1322
|
const composed = composeSideBySide(designBuf, renderBuf)
|
|
@@ -494,9 +1337,56 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
|
|
|
494
1337
|
|
|
495
1338
|
const elapsed = Math.round(performance.now() - t0)
|
|
496
1339
|
|
|
497
|
-
// 5.5 preflight
|
|
1340
|
+
// 5.5 preflight(机械化的 14 项)并入本轮报告
|
|
498
1341
|
const preflight = skipPreflight ? null : runPreflight({ cardId, specPath })
|
|
499
1342
|
|
|
1343
|
+
// 5.6 要素清单(内容真值)—— 只查得到就进报告,并作为**机器门禁的一项**(elements.missing=0)。
|
|
1344
|
+
//
|
|
1345
|
+
// 为什么必须进链路:实测(2026-09-21 demo 建卡回合)模型把设计稿的产品名 / 利率 / 期限
|
|
1346
|
+
// 全换成自己编的占位值(「理财优选1号 / 2.45 / 每日申购」),像素照样刷到 89%、
|
|
1347
|
+
// validate/lint 全绿,而卡片的内容真值**整批丢失**,直到人工比对设计稿才发现。
|
|
1348
|
+
// 根因是门禁只有「结构 + 像素」两维,没有「要素是否还在」这一维:
|
|
1349
|
+
// 设计稿的要素是固定真值,迭代只许改样式与布局 —— 丢了要素,再高的 match 也是错的。
|
|
1350
|
+
const fidelityReport = loadFidelityReport({ specPath, schemaPath: sp, designPath, cardId })
|
|
1351
|
+
|
|
1352
|
+
// 内容偏移折算到 1x 设计像素 + 对齐提示(先算好,contributors 的 hint 要用它)
|
|
1353
|
+
let contentOffset1x = null
|
|
1354
|
+
let contentOffsetHint = null
|
|
1355
|
+
if (pixelDiffResult?.contentOffset) {
|
|
1356
|
+
const scale = renderWidth ? (renderRes.width || renderWidth) / renderWidth : 2
|
|
1357
|
+
const co = pixelDiffResult.contentOffset
|
|
1358
|
+
contentOffset1x = {
|
|
1359
|
+
dx: Math.round(co.dx / (scale || 1)),
|
|
1360
|
+
dy: Math.round(co.dy / (scale || 1)),
|
|
1361
|
+
}
|
|
1362
|
+
const { dx, dy } = contentOffset1x
|
|
1363
|
+
if (Math.abs(dx) > GATE.contentOffset1x || Math.abs(dy) > GATE.contentOffset1x) {
|
|
1364
|
+
contentOffsetHint = `内容整体偏移 dx=${dx} dy=${dy}(1x 设计像素,渲染相对设计)——这是对齐问题不是间距微调问题:先查 Root/首层容器的左右与上下 padding、首个子节点的 margin、以及是否漏了 marginType/paddingType:"1";继续逐行调 gap/padding 不会收敛。`
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// 分项归因:把热区落到具体节点。这是「门禁说差多少」之外唯一能让模型不盲改的东西
|
|
1369
|
+
const contributors = attributeContributors({
|
|
1370
|
+
hotspots: pixelDiffResult?.hotspots,
|
|
1371
|
+
elements: renderRes.elementBoxes,
|
|
1372
|
+
design: designSide,
|
|
1373
|
+
contentOffset1x,
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
// 5.7 纯几何审计(隐形/出卡/折行)——「模型看不见渲染图」的机器替代品。
|
|
1377
|
+
//
|
|
1378
|
+
// 背景(2026-09-21):card_screenshot 只给人看、不进模型上下文,模型可观测输入只有
|
|
1379
|
+
// match/heightDelta/bands 三个数字,于是 Row 内横向容器默认 100% 吃掉整行这类
|
|
1380
|
+
// 「几何上确凿、像素上模糊」的缺陷被拖了 30 分钟。这三条判定不需要看图:
|
|
1381
|
+
// 被压成 0 尺寸、跑出卡片右缘、文本被折成一堆行,全是 getBoundingClientRect 的事实。
|
|
1382
|
+
const geometry = renderRes.elementBoxes
|
|
1383
|
+
? auditGeometry({
|
|
1384
|
+
elements: renderRes.elementBoxes,
|
|
1385
|
+
degenerate: renderRes.elementBoxesDegenerate,
|
|
1386
|
+
cardWidth: renderRes.width,
|
|
1387
|
+
})
|
|
1388
|
+
: null
|
|
1389
|
+
|
|
500
1390
|
const result = {
|
|
501
1391
|
ok: true,
|
|
502
1392
|
cardId,
|
|
@@ -524,34 +1414,59 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
|
|
|
524
1414
|
: undefined,
|
|
525
1415
|
designImage,
|
|
526
1416
|
preflight: preflight || undefined,
|
|
1417
|
+
fidelity: fidelityReport || undefined,
|
|
1418
|
+
// 内容就绪结论带进每轮报告:模型不必再单独跑一次 readiness 才知道内容层状态
|
|
1419
|
+
readiness: readiness
|
|
1420
|
+
? {
|
|
1421
|
+
ready: readiness.ready,
|
|
1422
|
+
stage: readiness.stage,
|
|
1423
|
+
blockers: readiness.blockers,
|
|
1424
|
+
// 声明了切图但 schema 里没有同名引用:只提示不拦(名字匹配不可靠,见 readiness.mjs 头部)
|
|
1425
|
+
unreferencedSlices: readiness.checks?.slices?.unreferenced || [],
|
|
1426
|
+
}
|
|
1427
|
+
: undefined,
|
|
1428
|
+
// 尺寸口径警告:宽度不对时下面所有像素指标都不可信,必须最先修
|
|
1429
|
+
widthMismatch: widthMismatch || undefined,
|
|
1430
|
+
// 结构性缺口:逐行几乎全异的连续带区 —— 整块漏建 / 背景错涂的第一信号。
|
|
1431
|
+
// 存在它时先修结构,跟着 contributors 调 padding/lineHeight 是在 80% 缺失面前做 1% 微调(gen-1 实测烧 8 轮)。
|
|
1432
|
+
structuralGaps: pixelDiffResult?.structuralGaps?.length
|
|
1433
|
+
? {
|
|
1434
|
+
bands: pixelDiffResult.structuralGaps,
|
|
1435
|
+
note: '检测到「逐行差异率 ≥85%」的连续带区(1x 卡内坐标,按高度降序):该带要么整块内容漏建(图表 / 卡片分区 / 列表段),要么背景/底色整层错涂。对照 spec_audit 的 bands[] 找到该带区对应的元素群,先把它建出来或改对背景再谈别的;match<40 时 contributors/hotspots 的局部微调线索可以全部忽略。',
|
|
1436
|
+
}
|
|
1437
|
+
: undefined,
|
|
1438
|
+
contentOffset1x: contentOffset1x || undefined,
|
|
1439
|
+
contentOffsetHint: contentOffsetHint || undefined,
|
|
1440
|
+
// 分项归因:每个热区给出「最可能的节点 + 它自己实测的 h / 同类中位 / 设计稿高度」
|
|
1441
|
+
contributors: contributors || undefined,
|
|
1442
|
+
// 纯几何隐形节点清单:不依赖看图,直接列出「用户看不见的东西」及其成因
|
|
1443
|
+
geometry: geometry || undefined,
|
|
1444
|
+
geometryNote: geometry
|
|
1445
|
+
? 'geometry 是本轮渲染的纯几何审计(round1 使用 1x 设计像素):collapsed = 被压成零尺寸、outOfCard = 跑出卡片被裁、overWrapped = 文本折行数异常。**门禁只把 collapsed / outOfCard 当 ERROR**(这两类用户必然看不见);逐条按 cid 去 schema 里改那一处布局,别整份重写。'
|
|
1446
|
+
: undefined,
|
|
1447
|
+
contributorsNote: contributors
|
|
1448
|
+
? 'contributors 按热区给出最可能出问题的**具体节点**(cid/cls/text):先改最内层那个节点的 padding/lineHeight,一轮只改一项;flags 里 vs-siblings = 与该类元素中位高度不同,vs-design = 与设计稿高度不同,text-clipped = 文本被裁剪。'
|
|
1449
|
+
: undefined,
|
|
527
1450
|
...(withElements ? { elements: renderRes.elementBoxes || [] } : {}),
|
|
528
1451
|
elapsedMs: elapsed,
|
|
529
1452
|
}
|
|
530
1453
|
|
|
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
1454
|
// 报告落盘 + 门禁判定 + 停滞检测
|
|
546
|
-
const saved = persistReport(result, { sp, dp })
|
|
1455
|
+
const saved = persistReport(result, { sp, dp, irPath, reset: resetFlag, design: designPath })
|
|
547
1456
|
if (saved) {
|
|
548
1457
|
result.report = saved.reportPath
|
|
549
1458
|
result.rounds = saved.rounds
|
|
550
1459
|
result.gate = saved.gate
|
|
1460
|
+
// 换设计稿 / --reset 触发的重置必须显式回喂模型:否则它会拿旧报告里的 match/best 当参照
|
|
1461
|
+
if (saved.iterationReset) result.iterationReset = saved.iterationReset
|
|
1462
|
+
// 显式结论一路带到工具输出:模型与用户都能一眼看出这卡是 pass / stalled / 还在 iterating,
|
|
1463
|
+
// 而不是只拿到一个 passed:false 的布尔(旧版因此出现「2 轮后无声转向下一张卡」)。
|
|
1464
|
+
result.conclusion = saved.conclusion
|
|
551
1465
|
if (saved.stalled) {
|
|
1466
|
+
// hint 由 persistReport 生成(那里才有历史与 bestIr 路径),这里只按设计稿路径补全 diff_bands 命令
|
|
552
1467
|
result.stalled = {
|
|
553
|
-
|
|
554
|
-
hint:
|
|
1468
|
+
...saved.stalled,
|
|
1469
|
+
hint: String(saved.stalled.hint || '').replaceAll('<设计稿>', designPath || '<设计稿>').replaceAll('<本轮 renderImage>', renderImagePath || '<renderImage>'),
|
|
555
1470
|
}
|
|
556
1471
|
}
|
|
557
1472
|
}
|
|
@@ -561,7 +1476,7 @@ async function runPipeline({ ir: irPath, out: outDir, design: designPath, width,
|
|
|
561
1476
|
|
|
562
1477
|
/* ── CLI ───────────────────────────────────────────────────── */
|
|
563
1478
|
function parseArgs(argv) {
|
|
564
|
-
const out = { ir: '', out: '', design: '', width: 0, keepAlive: false, diffImage: false, elements: false, sets: [], spec: '', gate: false, id: '', skipPreflight: false, slicesDir: '' }
|
|
1479
|
+
const out = { ir: '', out: '', design: '', width: 0, keepAlive: false, diffImage: false, elements: false, sets: [], spec: '', gate: false, id: '', skipPreflight: false, slicesDir: '', reset: false, noReadiness: false }
|
|
565
1480
|
for (let i = 0; i < argv.length; i++) {
|
|
566
1481
|
const a = argv[i]
|
|
567
1482
|
if (a === '--ir') out.ir = argv[++i] || ''
|
|
@@ -574,19 +1489,32 @@ function parseArgs(argv) {
|
|
|
574
1489
|
else if (a === '--id') out.id = argv[++i] || ''
|
|
575
1490
|
else if (a === '--gate') out.gate = true
|
|
576
1491
|
else if (a === '--no-preflight') out.skipPreflight = true
|
|
1492
|
+
// 逃生门:跳过 S4.9 内容就绪门禁(debug 用;正常流程**不该**用它)
|
|
1493
|
+
else if (a === '--no-readiness') out.noReadiness = true
|
|
577
1494
|
else if (a === '--keep-alive') out.keepAlive = true
|
|
578
1495
|
else if (a === '--diff-image') out.diffImage = true
|
|
579
1496
|
else if (a === '--elements') out.elements = true
|
|
1497
|
+
// 换设计稿时轮次历史/best 会自动作废(见 iterationIdentity);--reset 用于「设计稿没换但要从零计时」
|
|
1498
|
+
else if (a === '--reset') out.reset = true
|
|
580
1499
|
else if (a === '-h' || a === '--help') {
|
|
581
1500
|
console.log(`Usage: node iterate.mjs --ir <IR.json> --out <cardDir> [--design <design.png>] [--width 343] [--elements]
|
|
582
1501
|
[--spec <spec.json>](跑 preflight 元素清单比对) [--no-preflight] [--slices-dir <要素包 slices 目录>]
|
|
583
1502
|
[--set 'children.0.s.margin.marginTop="7"'](可重复;点路径数字段=数组下标;原子写回 IR)
|
|
584
|
-
node iterate.mjs --gate --out <cardDir>(或 --id <cardId>):读最近一轮落盘报告做机器门禁判定,未达标 exit
|
|
1503
|
+
node iterate.mjs --gate --out <cardDir>(或 --id <cardId>):读最近一轮落盘报告做机器门禁判定,未达标 exit 2(gate-fail,不是脚本报错)
|
|
585
1504
|
node iterate.mjs --keep-alive (stdin JSON lines → stdout JSON lines)
|
|
586
1505
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1506
|
+
阶段(gate.stage):content → structure → pixel → pass,**逐段推进**。
|
|
1507
|
+
· content 内容未就绪(S4.9):要素 / 变量绑定 / 图片地址 / 切图落盘 有缺 ⇒ **跳过 render 与 pixelDiff、
|
|
1508
|
+
不消耗迭代轮次**,按 gate.verdict 里的 blockers 补齐内容再来。跳过用 --no-readiness(或环境变量
|
|
1509
|
+
DSL_CARD_SKIP_READINESS=1),正常流程不该用。
|
|
1510
|
+
· structure validate/lint/preflight/geometry/要素线不达标 —— 先改结构,别碰像素。
|
|
1511
|
+
· pixel 结构全过,只差 match / 尺寸 / 整体偏移。
|
|
1512
|
+
|
|
1513
|
+
门禁(--gate / 报告里的 gate 字段):readiness=ready ∧ validate.errors=0 ∧ lint.errors=0 ∧ preflight.errors=0
|
|
1514
|
+
∧ elements.missing=0 ∧ match>=${GATE.match} ∧ heightDelta=${GATE.heightDelta} ∧ widthDelta=${GATE.widthDelta}
|
|
1515
|
+
∧ |contentOffset1x|<=${GATE.contentOffset1x} ∧ geometry.collapsed=0 ∧ geometry.outOfCard=0,
|
|
1516
|
+
且 schema 未在报告之后被改过。
|
|
1517
|
+
报告落盘:.cache/ai-scratch/<cardId>.iterate.json(含 gate / history / stalled / geometry / blockedAt)。`)
|
|
590
1518
|
process.exit(0)
|
|
591
1519
|
}
|
|
592
1520
|
}
|
|
@@ -620,9 +1548,31 @@ function runGateMode({ id, out: outDir }) {
|
|
|
620
1548
|
gate.verdict = `FAIL:报告已过期(schema.json 在报告之后被改过:${report.schemaHash} → ${schemaHash})—— 重跑一轮 iterate.mjs 再判门禁`
|
|
621
1549
|
gate.checks.push({ name: 'reportFreshness', value: 'stale', required: 'schema 未变更', ok: false })
|
|
622
1550
|
}
|
|
1551
|
+
// 显式结论:pass / stalled / blocked-content / iterating。四者之外不允许「无声退出」——
|
|
1552
|
+
// 实测建卡回合里模型跑完 2 轮就直接转去做下一张卡,卡在「既没过门禁、也没判停滞」的
|
|
1553
|
+
// 中间态,界面上完全看不出这卡是被放弃了还是还在迭代。
|
|
1554
|
+
const blockedContent = Boolean(report.blockedAt) || report.conclusion === 'blocked-content'
|
|
1555
|
+
const conclusion = gate.passed ? 'pass' : blockedContent ? 'blocked-content' : report.stalled ? 'stalled' : 'iterating'
|
|
1556
|
+
if (blockedContent) {
|
|
1557
|
+
gate.blocked = true
|
|
1558
|
+
gate.stage = 'content'
|
|
1559
|
+
gate.verdict =
|
|
1560
|
+
`BLOCKED(内容未就绪,stage=${report.blockedAt?.stage || '?'}):本轮没跑 render / 没跑 pixelDiff,也没消耗迭代轮次。\n` +
|
|
1561
|
+
(report.blockedAt?.blockers || []).map((b) => ` · ${b}`).join('\n') +
|
|
1562
|
+
(report.blockedAt?.hint ? `\n修法:${report.blockedAt.hint}` : '')
|
|
1563
|
+
} else if (conclusion === 'iterating') {
|
|
1564
|
+
gate.verdict +=
|
|
1565
|
+
';结论=iterating:既未过门禁、也未判停滞 —— 不允许无声退出。' +
|
|
1566
|
+
'要么继续 iterate(一轮只改一项,拿到新报告再判),要么在正文里显式写明放弃理由(例:设计稿本身自相矛盾 / 缺关键切图)再停手。'
|
|
1567
|
+
}
|
|
623
1568
|
const out = {
|
|
624
1569
|
ok: gate.passed,
|
|
625
1570
|
cardId,
|
|
1571
|
+
conclusion,
|
|
1572
|
+
blockedAt: report.blockedAt || null,
|
|
1573
|
+
stalled: report.stalled
|
|
1574
|
+
? { reason: report.stalled.reason || 'match', rounds: report.stalled.rounds, hint: report.stalled.hint }
|
|
1575
|
+
: null,
|
|
626
1576
|
report: reportPath,
|
|
627
1577
|
gate,
|
|
628
1578
|
summary: {
|
|
@@ -631,6 +1581,7 @@ function runGateMode({ id, out: outDir }) {
|
|
|
631
1581
|
widthDelta: report.pixelDiff?.size?.widthDelta ?? null,
|
|
632
1582
|
contentOffset1x: report.contentOffset1x || null,
|
|
633
1583
|
validateErrors: report.validate?.errors ?? null,
|
|
1584
|
+
envBlocked: report.validate?.envError === true ? true : undefined,
|
|
634
1585
|
lintErrors: report.lint?.errors ?? null,
|
|
635
1586
|
preflightErrors: report.preflight?.errors ?? null,
|
|
636
1587
|
rounds: (report.history || []).length,
|
|
@@ -638,7 +1589,10 @@ function runGateMode({ id, out: outDir }) {
|
|
|
638
1589
|
},
|
|
639
1590
|
}
|
|
640
1591
|
console.log(JSON.stringify(out, null, 2))
|
|
641
|
-
|
|
1592
|
+
// 门禁未达标 = 正常诊断结果(gate-fail),不是「脚本报错收尾」。
|
|
1593
|
+
// 必须与 exit 1 分开:服务端按 exit 语义记账,exit 1 会被记成「连续失败」并触发短路
|
|
1594
|
+
// (实测 2026-09-21 demo 建卡回合:--gate 被误判失败 1 次,模型因此被误导去改别的地方)。
|
|
1595
|
+
process.exit(gate.passed ? 0 : 2)
|
|
642
1596
|
}
|
|
643
1597
|
|
|
644
1598
|
async function main() {
|
|
@@ -658,7 +1612,7 @@ async function main() {
|
|
|
658
1612
|
if (!text) continue
|
|
659
1613
|
try {
|
|
660
1614
|
const req = JSON.parse(text)
|
|
661
|
-
const result = await runPipeline({ ...req, specPath: req.spec || req.specPath, session, alwaysDiffImage: req.diffImage || req.alwaysDiffImage, withElements: req.elements })
|
|
1615
|
+
const result = await runPipeline({ ...req, specPath: req.spec || req.specPath, session, alwaysDiffImage: req.diffImage || req.alwaysDiffImage, withElements: req.elements, noReadiness: req.noReadiness || req.skipReadiness })
|
|
662
1616
|
console.log(JSON.stringify(result))
|
|
663
1617
|
} catch (e) {
|
|
664
1618
|
console.log(JSON.stringify({ ok: false, error: 'request-failed', message: e.message }))
|
|
@@ -686,6 +1640,8 @@ async function main() {
|
|
|
686
1640
|
specPath: args.spec,
|
|
687
1641
|
skipPreflight: args.skipPreflight,
|
|
688
1642
|
slicesDir: args.slicesDir,
|
|
1643
|
+
reset: args.reset,
|
|
1644
|
+
noReadiness: args.noReadiness,
|
|
689
1645
|
})
|
|
690
1646
|
|
|
691
1647
|
console.log(JSON.stringify(result, null, 2))
|
|
@@ -694,7 +1650,12 @@ async function main() {
|
|
|
694
1650
|
if (result.gate && result.gate.passed === false) process.exitCode = 2
|
|
695
1651
|
}
|
|
696
1652
|
|
|
697
|
-
main()
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1653
|
+
// 仅当作为 CLI 直接运行时才启动 main()(被 import 时不执行:停滞判据可离线重放验证)
|
|
1654
|
+
const _thisScript = fs.realpathSync(fileURLToPath(import.meta.url))
|
|
1655
|
+
const _invokedAs = process.argv[1] && fs.existsSync(process.argv[1]) ? fs.realpathSync(process.argv[1]) : process.argv[1]
|
|
1656
|
+
if (_invokedAs === _thisScript) {
|
|
1657
|
+
main().catch((e) => {
|
|
1658
|
+
console.error(`iterate.mjs 未预期错误: ${e.message || e}`)
|
|
1659
|
+
process.exit(1)
|
|
1660
|
+
})
|
|
1661
|
+
}
|