New:Socket for Asana Is Now Available.Learn more
Get Started

@double-coding/pixel-print

Package Overview
Dependencies
Maintainers
2
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@double-coding/pixel-print - npm Package Compare versions

Comparing version
1.3.1
to
1.4.0
+104
templates/skills/p...c-fast/bin/rules/R03-implicit-image.mjs
// R03 implicit-image(v1.2.3 软→硬迁移,极保守)
// 触发: 无任何前缀 + 子树纯几何/容器 + 无 TEXT/INSTANCE/COMPONENT + 无 btn-/input-/sub-/block- 子节点
// 且子树含 ≥3 个「真矢量路径」(VECTOR/BOOLEAN_OPERATION/STAR/REGULAR_POLYGON,CSS 难还原) → 该整体切图
// 期望: assets.txt 有切图记录 且 产物引用(<img>/background url)
// 保守: RECTANGLE/ELLIPSE/LINE 等可 CSS 化的简单形状不计入「必切」信号;阈值 ≥3 真矢量;
// 只抓「一堆真实矢量路径堆叠却没切图」,避免对装饰圆点/单形状/可 CSS 化图形误判。
import fs from 'node:fs';
import path from 'node:path';
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R03';
export const name = 'implicit-image';
const PREFIXES = ['img-', 'bg-', 'bgc-', 'x-', 'input-', 'sub-', 'block-', 'btn-', 'fixed-', 'end-', 'scrollx-', 'scrolly-'];
const GEOM = new Set(['VECTOR', 'BOOLEAN_OPERATION', 'RECTANGLE', 'ELLIPSE', 'STAR', 'REGULAR_POLYGON', 'LINE']);
const HARD_VECTOR = new Set(['VECTOR', 'BOOLEAN_OPERATION', 'STAR', 'REGULAR_POLYGON']);
const CONTAINER = new Set(['GROUP', 'FRAME']);
const DISQUALIFY_TYPE = new Set(['TEXT', 'INSTANCE', 'COMPONENT', 'COMPONENT_SET']);
const DISQUALIFY_PREFIX = ['btn-', 'input-', 'sub-', 'block-'];
const HARD_VECTOR_THRESHOLD = 3;
export function check({ cache, product, classMap }) {
const violations = [];
const assetsText = readAssets(product);
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
if (hasPrefix(node.name)) continue;
if (!Array.isArray(node.children) || node.children.length === 0) continue; // 叶子不判
const stat = scanSubtree(node);
if (!stat.ok) continue; // 含 TEXT/交互/复合前缀/非几何 → 不判
if (stat.hardVectorCount < HARD_VECTOR_THRESHOLD) continue; // 极保守阈值
const inAssets = assetsText.includes(nodeId);
const productRef = mentionsAsset(product, nodeId, classMap);
if (!inAssets && !productRef) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `整体切图(子树含 ${stat.hardVectorCount} 个矢量路径,CSS 难还原) + 产物引用`,
actual: 'assets.txt 无切图记录 且 产物未引用该 nodeId 的 img / background url',
file: '(missing)',
line: 0,
snippet: '',
});
}
}
return violations;
}
function hasPrefix(name) {
if (!name || typeof name !== 'string') return false;
const n = name.trim();
return PREFIXES.some((p) => n.startsWith(p));
}
// 遍历子树:命中 disqualify 立即 ok=false;统计真矢量数
function scanSubtree(root) {
let hardVectorCount = 0;
let ok = true;
const stack = [...(root.children || [])];
while (stack.length) {
const c = stack.shift();
if (!c || typeof c !== 'object') continue;
if (DISQUALIFY_TYPE.has(c.type)) { ok = false; break; }
if (typeof c.name === 'string' && DISQUALIFY_PREFIX.some((p) => c.name.startsWith(p))) { ok = false; break; }
if (c.type && !GEOM.has(c.type) && !CONTAINER.has(c.type)) { ok = false; break; } // 非几何/容器 → 不判
if (HARD_VECTOR.has(c.type)) hardVectorCount++;
if (Array.isArray(c.children)) stack.push(...c.children);
}
return { ok, hardVectorCount };
}
function readAssets(product) {
try {
const p = path.join(product.root, 'assets.txt');
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
} catch {
return '';
}
}
function mentionsAsset(product, nodeId, classMap) {
const idNorm = nodeId.replace(/:/g, '-');
for (const j of product.jsx || []) {
if (j.content.includes(nodeId) || j.content.includes(idNorm)) return true;
}
const classes = classMap[nodeId] || [];
for (const cls of classes) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, cls)) {
if (/url\(/i.test(r.body) || /background-image\s*:/i.test(r.body)) return true;
}
}
}
for (const s of product.style) {
if (s.content.includes(nodeId) || s.content.includes(idNorm)) return true;
}
return false;
}
// R04 text-gradient(v1.2.3 软→硬迁移)
// 触发: TEXT 节点,fills 非空,末位可见 fill 是 GRADIENT_*/IMAGE
// 期望: 对应 CSS 类含 background-clip: text(或 -webkit-background-clip: text)
// —— 渐变/图案字必须走 background-clip:text 方案,不能用 solid color 冒充
// 排斥: 末位可见 fill 是 SOLID → 归 R06;baked/hidden/templateDup 跳过;无 className 交 R21
// 保守: 只在「末位 fill 确为 GRADIENT/IMAGE 且 CSS 完全没有 background-clip:text」时报,
// gradient 具体色值/角度不校验(避免格式差异误判)。与 R06 同一套 TEXT-fills 判定机制。
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R04';
export const name = 'text-gradient';
const GRADIENT_TYPES = new Set([
'GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR', 'GRADIENT_DIAMOND', 'IMAGE',
]);
export function check({ cache, product, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node.type !== 'TEXT') continue;
if (!Array.isArray(node.fills) || node.fills.length === 0) continue;
if (node._inBakedSubtree) continue; // 文字像素已烤进父层 PNG,禁 DOM 交 R17
if (node._hidden) continue;
if (node._templateDup) continue; // .map() 数据副本,只校验代表项
const lastVisible = pickLastVisibleFill(node.fills);
if (!lastVisible) continue; // 全 invisible
if (!GRADIENT_TYPES.has(lastVisible.type)) continue; // SOLID → R06;其余类型不判
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue; // 不可追溯 → R21 统一报,避免双报
let hasClipText = false;
let hitFile = null;
let hitLine = 0;
let hitSnippet = '';
for (const cls of classes) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, cls)) {
if (/(?:-webkit-)?background-clip\s*:\s*text/i.test(r.body)) {
hasClipText = true;
break;
}
if (!hitFile) { hitFile = s.rel; hitLine = r.line; hitSnippet = r.body.slice(0, 200); }
}
if (hasClipText) break;
}
if (hasClipText) break;
}
if (!hasClipText) {
const kind = lastVisible.type === 'IMAGE' ? '图案(IMAGE)' : `渐变(${lastVisible.type})`;
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `css 含 background-clip: text(源自 fills 末位可见 ${kind})`,
actual: hitFile
? 'css 未含 background-clip: text(疑用 solid color 冒充渐变/图案字)'
: 'css 未找到该 class 规则体',
file: hitFile || '(missing in style)',
line: hitLine,
snippet: hitSnippet,
});
}
}
return violations;
}
function pickLastVisibleFill(fills) {
for (let i = fills.length - 1; i >= 0; i--) {
const f = fills[i];
if (f && f.visible !== false) return f;
}
return null;
}
// R09 btn-bgc-取值(v1.2.3 软→硬迁移)
// 触发: btn- 节点子树含 bgc- 子层,且 bgc- 末位可见 fill 是 GRADIENT_*
// 期望: btn-(或 bgc-)对应 CSS background 用 gradient 形态(linear/radial/conic-gradient)
// 保守: 只判「该有 gradient 却是 solid/缺失」,不校验具体色值/角度(避免格式误判);
// bgc 末位 SOLID → 按 background-color 取,不算 R09;bgc IMAGE → 归 R02。
// gradient 形态出现在 btn 或 bgc 任一 class 即通过,避免「bgc 渲染成子 div」误判。
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R09';
export const name = 'btn-bgc-取值';
const GRAD = new Set(['GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR', 'GRADIENT_DIAMOND']);
export function check({ cache, product, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node.name || !node.name.startsWith('btn-')) continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const bgc = findBgcDescendant(node);
if (!bgc) continue;
const last = pickLastVisibleFill(bgc.fills);
if (!last || !GRAD.has(last.type)) continue; // 只在 bgc 末位可见是 GRADIENT 时判
// 收集 btn + bgc 两处 class(background 可能写在任一处)
const classes = [...(classMap[nodeId] || []), ...(bgc.id ? classMap[bgc.id] || [] : [])];
if (classes.length === 0) continue; // 不可追溯 → R21
let hasGradient = false;
let hasSolidBg = false;
let hitFile = null;
let hitLine = 0;
let hitSnippet = '';
for (const cls of classes) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, cls)) {
if (/\b(?:linear|radial|conic)-gradient\s*\(/i.test(r.body)) { hasGradient = true; break; }
if (/background(?:-color)?\s*:\s*(?:#|rgb|hsl)/i.test(r.body)) hasSolidBg = true;
if (!hitFile) { hitFile = s.rel; hitLine = r.line; hitSnippet = r.body.slice(0, 200); }
}
if (hasGradient) break;
}
if (hasGradient) break;
}
if (!hasGradient) {
violations.push({
rule: id,
nodeId,
name: node.name,
type: node.type,
expected: `css background 用 gradient(取自 bgc- 子层末位 ${last.type})`,
actual: hasSolidBg
? 'css background 是 solid color(疑用 solid 冒充渐变)'
: 'css 未含 gradient 形态',
file: hitFile || '(missing in style)',
line: hitLine,
snippet: hitSnippet,
});
}
}
return violations;
}
function findBgcDescendant(node) {
if (!Array.isArray(node.children)) return null;
const stack = [...node.children];
while (stack.length) {
const c = stack.shift();
if (!c || typeof c !== 'object') continue;
if (typeof c.name === 'string' && c.name.startsWith('bgc-')) return c;
if (Array.isArray(c.children)) stack.push(...c.children);
}
return null;
}
function pickLastVisibleFill(fills) {
if (!Array.isArray(fills)) return null;
for (let i = fills.length - 1; i >= 0; i--) {
const f = fills[i];
if (f && f.visible !== false) return f;
}
return null;
}
// R12 flat-mode-naming(v1.2.3 软→硬迁移)
// 触发: config.merge.mode === 'flat'(所有 block 产物合并到一个文件)
// 期望: 同一 className 不被多个顶层规则体重复定义(合并后互相覆盖)
// 保守: 只统计「纯 .class {」顶层选择器,不含 .class:hover / .a .class / .class.x 等修饰形态;
// config 无 merge.mode 或 ≠ flat → 直接放行(安全降级)。
export const id = 'R12';
export const name = 'flat-mode-naming';
export function check({ product, config }) {
if (!config || !config.merge || config.merge.mode !== 'flat') return [];
const counts = new Map(); // class -> [{ rel, line }]
const re = /(?:^|[\s}])\.([a-zA-Z_][\w-]*)\s*\{/g;
for (const s of product.style) {
let m;
while ((m = re.exec(s.content)) !== null) {
const cls = m[1];
const line = s.content.slice(0, m.index).split('\n').length;
if (!counts.has(cls)) counts.set(cls, []);
counts.get(cls).push({ rel: s.rel, line });
}
re.lastIndex = 0;
}
const violations = [];
for (const [cls, occ] of counts) {
if (occ.length >= 2) {
violations.push({
rule: id,
nodeId: '(n/a)',
name: `.${cls}`,
type: 'CSS',
expected: `flat 模式下 className 唯一;.${cls} 应带 block 前缀区分(如 .topbar${cap(cls)})`,
actual: `.${cls} 被定义 ${occ.length} 次(合并后互相覆盖): ${occ.map((o) => `${o.rel}:${o.line}`).join(', ')}`,
file: occ[0].rel,
line: occ[0].line,
snippet: '',
});
}
}
return violations;
}
function cap(s) {
return s ? s[0].toUpperCase() + s.slice(1) : s;
}
// R14 fixed-z-index(v1.2.3 软→硬迁移)
// 触发: ≥2 个 fixed- 节点(可追溯、非 baked/hidden)
// 期望: 各 fixed 有 z-index 且不全相同(层级可区分)
// 保守: 只报「全部缺 z-index」或「全部 z-index 相同」这两种铁定覆盖的情形;
// 不强求具体递增序/具体值(那有合理变体,会误判)。单个 fixed → 不判。
import { findProperty } from '../lib/cssMatch.mjs';
export const id = 'R14';
export const name = 'fixed-z-index';
export function check({ cache, product, classMap }) {
const fixed = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node.name || !node.name.startsWith('fixed-')) continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue; // 不可追溯 → R21
fixed.push({ nodeId, node, classes });
}
if (fixed.length < 2) return []; // 单个/无 → 无层级冲突
const zvals = fixed.map((f) => {
const r = findProperty(product.style, f.classes, /z-index\s*:\s*-?\d+/i);
if (r.hit) {
const m = r.body.match(/z-index\s*:\s*(-?\d+)/i);
return { ...f, z: m ? m[1] : null, rel: r.rel, line: r.line };
}
return { ...f, z: null, rel: r.firstRel, line: r.firstLine };
});
const allMissing = zvals.every((v) => v.z === null);
const present = zvals.filter((v) => v.z !== null).map((v) => v.z);
const allSame = present.length === zvals.length && new Set(present).size === 1;
if (!allMissing && !allSame) return []; // 有区分 → 放行
const list = zvals.map((v) => `${v.node.name}=${v.z ?? '(缺)'}`).join(', ');
return [{
rule: id,
nodeId: zvals[0].nodeId,
name: zvals[0].node.name,
type: zvals[0].node.type,
expected: '多个 fixed- 元素 z-index 应存在且不全相同(层级可区分)',
actual: allMissing ? `全部 fixed- 未设 z-index: ${list}` : `全部 fixed- z-index 相同: ${list}`,
file: zvals[0].rel || '(style)',
line: zvals[0].line || 0,
snippet: '',
}];
}
// R22 empty-visual-btn(v1.2.4 新增,warning 级不阻断)
// 触发: btn- 节点在产物中存在(有 className),但自身与子树均无可见视觉——
// CSS 无 background/渐变、JSX 无 <img> 挂载、子树无可见 TEXT、bbox 面积 > 0
// → 空视觉按钮(透明热区)嫌疑(典型 test24 btn-qiang: cache 深度截断丢内容,产物只剩热区)。
// 保守: 仅 warning——部分设计确实用透明热区叠在整图上(bg- 父层已含按钮视觉),不能 exit 1;
// 但必须让主 agent 在 QA 段看见并复核(常见根因: cache 截断 / 该切图没切 / 漏画内容)。
// 跳过: baked / hidden / templateDup / 无 className。
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R22';
export const name = 'empty-visual-btn';
const VISUAL_CSS = /background(?:-image|-color)?\s*:|(?:linear|radial|conic)-gradient\s*\(|url\s*\(/i;
export function check({ cache, product, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
const nm = (node.name || '').trim();
if (!(nm.startsWith('btn-') || nm === 'btn')) continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const bbox = node.absoluteBoundingBox;
if (!bbox || !(bbox.width > 0 && bbox.height > 0)) continue;
if (!classMap[nodeId] || classMap[nodeId].length === 0) continue; // 不可追溯,交 R21
// 子树任一可见 TEXT → 文字按钮,有视觉
if (subtreeHasVisibleText(node)) continue;
// 自身或子树任一有 className 的节点,其 CSS 含背景/渐变/url → 有视觉
const ids = collectSubtreeIds(node, cache.nodes, nodeId);
if (ids.some((id2) => hasVisualCss(product.style, classMap[id2] || []))) continue;
// 子树任一节点在 JSX 中以 <img> 呈现,或标签上带内联背景(style={{backgroundImage}}) → 有视觉
if (ids.some((id2) => jsxHasImg(product.jsx, id2) || jsxHasInlineVisual(product.jsx, id2))) continue;
violations.push({
rule: id,
severity: 'warning',
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: 'btn- 节点应有可见视觉(文字/背景/图片);纯透明热区须人工确认是否叠在整图上',
actual: '产物 button 无文字、无 background、无 <img>,疑似空视觉按钮(常见根因: cache 深度截断 / 该切图没切)',
file: '(style)',
line: 0,
snippet: '',
});
}
return violations;
}
function subtreeHasVisibleText(root) {
let found = false;
const walk = (n) => {
if (found || !n || typeof n !== 'object') return;
if (n.visible === false) return;
if (n.type === 'TEXT' && String(n.characters || '').trim()) { found = true; return; }
for (const c of n.children || []) walk(c);
};
walk(root);
return found;
}
// btn 子树全部节点 id(含自身);以 cache.nodes 的 _parentId 链兜底,树上直接走 children
function collectSubtreeIds(root, cacheNodes, rootId) {
const ids = [];
const walk = (n) => {
if (!n || typeof n !== 'object') return;
if (n.id) ids.push(n.id);
for (const c of n.children || []) walk(c);
};
walk(root);
if (ids.length === 0) ids.push(rootId);
return ids;
}
function hasVisualCss(styleFiles, classes) {
for (const cls of classes) {
for (const s of styleFiles) {
for (const b of collectRuleBodies(s.content, cls)) {
if (VISUAL_CSS.test(b.body)) return true;
}
}
}
return false;
}
function jsxHasImg(jsxFiles, nodeId) {
const esc = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`<img[^>]*data-node-id=["']${esc}["']`, 'i');
return jsxFiles.some((f) => re.test(f.content));
}
// 切图消费契约允许 JSX 内联 style={{ backgroundImage: ... }} 挂图,同一标签内出现 background 即算有视觉
function jsxHasInlineVisual(jsxFiles, nodeId) {
const esc = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`<[a-zA-Z][^>]*data-node-id=["']${esc}["'][^>]*>`, 'i');
for (const f of jsxFiles) {
const m = f.content.match(re);
if (m && /background/i.test(m[0])) return true;
}
return false;
}
// R23 size-fidelity(v1.2.5 新增)
// 触发: 产物为节点显式声明了 px 宽/高,但与 cache bbox × scale 相差 > 4px。
// 特判: 1px×1px + overflow:hidden 且真实 bbox 面积远大于 1 → 「锚点欺诈」——
// 典型 test28 __screen-ref: 真实 331.5×141(应 663×282px)被写成 1×1 隐藏 div,
// 专为骗过 R02/R21 的存在性+引用检查(agent 自供"校验锚点")。
// 保守跳过(宁漏报不误判):
// - 未声明 px 宽/高(HUG 不写宽、FILL 写 100%、auto/fit-content) → 布局驱动,不判
// - TEXT 节点(字体渲染尺寸与 bbox 天然有出入) → 不判
// - 声明了 padding 且全部规则体均无 box-sizing: border-box → 盒模型不确定,不判
// - baked / hidden / templateDup / 无 className / 无 bbox → 不判
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R23';
export const name = 'size-fidelity';
const TOL = 4;
export function check({ cache, product, config, classMap }) {
const violations = [];
const scale = (config && config.unit && config.unit.scale) || 2;
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
if (node.type === 'TEXT') continue;
const bbox = node.absoluteBoundingBox;
if (!bbox || !(bbox.width > 0 && bbox.height > 0)) continue;
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue;
const bodies = allBodies(product.style, classes);
if (bodies.length === 0) continue;
const declW = lastPx(bodies, 'width');
const declH = lastPx(bodies, 'height');
if (declW == null && declH == null) continue; // 布局驱动尺寸,不判
const expW = Math.round(bbox.width * scale);
const expH = Math.round(bbox.height * scale);
// 锚点欺诈特判: 1×1 + overflow:hidden + 真实尺寸远大于 1
const hasOverflowHidden = bodies.some((b) => /overflow\s*:\s*hidden/i.test(b));
if (declW === 1 && declH === 1 && hasOverflowHidden && expW > 8 && expH > 8) {
violations.push(v(nodeId, node, `width:${expW}px height:${expH}px(bbox×${scale})`,
`1px×1px+overflow:hidden 锚点欺诈——DOM 仅为骗过存在性/引用检查而存在,视觉未渲染(真实 ${expW}×${expH}px)`));
continue;
}
// 盒模型不确定 → 保守跳过
const hasPadding = bodies.some((b) => /(?:^|[^-\w])padding(?:-\w+)?\s*:/i.test(b));
const hasBorderBox = bodies.some((b) => /box-sizing\s*:\s*border-box/i.test(b));
if (hasPadding && !hasBorderBox) continue;
const problems = [];
if (declW != null && Math.abs(declW - expW) > TOL) problems.push(`width=${declW}px 应 ${expW}px`);
if (declH != null && Math.abs(declH - expH) > TOL) problems.push(`height=${declH}px 应 ${expH}px`);
if (problems.length) {
violations.push(v(nodeId, node, `width≈${expW}px height≈${expH}px(bbox×${scale},容差 ${TOL}px)`, problems.join(';')));
}
}
return violations;
}
function v(nodeId, node, expected, actual) {
return {
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected,
actual,
file: '(style)',
line: 0,
snippet: '',
};
}
function allBodies(styleFiles, classes) {
const out = [];
for (const cls of classes) {
for (const s of styleFiles) {
for (const b of collectRuleBodies(s.content, cls)) out.push(b.body);
}
}
return out;
}
// 取该属性最后一次 px 声明(CSS 后写覆盖);无 px 声明(100%/auto/vw/未写)→ null
function lastPx(bodies, prop) {
let val = null;
const re = new RegExp(`(?:^|[^-\\w])${prop}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)px\\b`, 'gi');
for (const b of bodies) {
for (const m of b.matchAll(re)) val = parseFloat(m[1]);
}
return val;
}
# R22 - empty-visual-btn(v1.2.4 新增,warning 级)
## 判定归属
- **硬防线** (check-rules.mjs 自动识别): ✅(**warning 级**,提示不阻断、不 exit 1)
- **软防线** (Rule-Scan sub-agent 识别): ✅(生成前指引)
- **排斥条件**:
- 节点名非 `btn-` 前缀(或裸词 `btn`)→ 不适用
- `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过
- `classMap[nodeId]` 为空 → 不报,交 R21(不可追溯)
- 缺 `absoluteBoundingBox` 或面积为 0 → 跳过(不可见热区无视觉诉求)
## 触发条件
`btn-` 节点在产物中存在(有 className),但**自身与整棵子树都找不到任何可见视觉**——同时满足:
1. 子树无可见 TEXT(`visible !== false` 且 `characters` 非空白);
2. 自身与子树所有有 className 节点的 CSS 均无 `background` / `gradient` / `url(...)`;
3. 子树所有节点在 JSX 中均非 `<img>` 挂载,且标签上无内联 `style={{ background... }}`。
命中 → **warning**(不阻断)。
## 为什么是 warning 不是 error
部分设计确实用**透明热区**叠在整图上(`bg-` 父层已含按钮视觉),此时空视觉按钮是正确产物——机械判定无法区分"合法热区"与"内容丢失",按保守原则不 exit 1。但必须让主 agent 在 QA 段看见并**逐个复核**。
## 常见根因(复核清单)
1. **cache 深度截断**:`fetch-node --depth=N` 边界上的 GROUP children 为空,按钮真实内容不在 cache 里(典型 test24 btn-qiang 136:45810)→ 用 `figma.mjs fetch-node` 输出的 `truncatedSuspects` 核对,命中则不带 `--depth` 补拉子树后重生成。
2. **该切图没切**:内容是复杂矢量/图片组合,应命名 `btn-img-*`(可点击容器 + 内容为图片)走切图;只标 `btn-` 时生成器按 CSS 化处理,视觉丢失。
3. **合法透明热区**:视觉在 `bg-` 整图里 → 在 assets.txt 注明 `[R22 复核] {nodeId} 热区叠加于 {bg nodeId}`,消警。
## 期望产物
- 文字按钮:`<button>` + 子 TEXT `<span>` + 按 fills 写 `background`
- 图片按钮:改名 `btn-img-*` → 可点击容器 + 内容切图 `<img>` / `background-image`
- 透明热区(合法场景):产物不变,assets.txt 写复核记录
## 与相邻规则的边界
- **R09 btn-bgc**:管 `btn-` 内 `bgc-` 子层渐变取真值;R22 管"整个按钮什么视觉都没有"
- **R21 node-id-coverage**:btn- 节点没进产物由 R21 报;R22 只管"进了产物但没视觉"
- **R03 implicit-image**:管无前缀纯矢量堆;btn- 前缀节点被 R03 排斥,由 R22 接手
## Rule-Scan 提示要点
- 提示 UI sub-agent:btn- 子树若只有占位矩形 + 空 GROUP,先怀疑 cache 截断,再怀疑该切图没切;禁止静默生成透明热区不留痕
# R23 - size-fidelity(v1.2.5 新增)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅(生成前指引)
- **排斥条件**:
- 产物未显式声明 px 宽/高(HUG 不写宽、FILL 写 `100%`、`auto`/`fit-content`)→ 布局驱动,不判
- `TEXT` 节点(字体渲染尺寸与 bbox 天然有出入)→ 不判
- 声明了 padding 且全部规则体均无 `box-sizing: border-box` → 盒模型不确定,不判
- `_inBakedSubtree` / `_hidden` / `_templateDup` / 无 className / 无 bbox 或面积为 0 → 不判
## 触发条件
设 `scale` = `config.unit.scale`(默认 2),容差 4px:
- 期望 `width = bbox.width × scale`
- 期望 `height = bbox.height × scale`
**任一命中 → 违规**:
1. 产物声明的 px `width`/`height`(取同类规则体中最后一次声明,CSS 后写覆盖)与期望**相差 > 4px**
2. **锚点欺诈特判**:声明 `width: 1px; height: 1px` 且任一规则体含 `overflow: hidden`,而期望宽高均 > 8px → 直接点名"锚点欺诈"
## 为什么需要本规则
test28 实测:执行器把真实 331.5×141(应 663×282px)的节点写成 `1×1 + overflow:hidden` 隐藏 div——挂着 data-node-id 骗过 R21 存在性检查、塞 1px 背景图骗过 R02 引用检查,agent 自供这是"校验锚点"。当时**没有任何规则校验宽高忠实度**,1px 无人过问。本规则封死这个维度:产物敢写 px 数值,就必须与设计稿对得上。
## 期望产物
- 固定尺寸元素:`width/height = bbox × scale`(±4px)
- 布局驱动元素:不写死 px(HUG 不写宽 / FILL 写 `100%`),本规则自动跳过
- **禁止**用 1×1 隐藏锚点代替真实渲染;节点确实不该有独立视觉时,走 baked 机制(`bg-`/`img-` 前缀)或与用户确认后不出 DOM
## 与相邻规则的边界
- **R19 padding**:管盒内间距忠实度;R23 管盒本身尺寸
- **R20 absolute-position**:管坐标与 position 声明;R23 管宽高
- **R21 node-id-coverage**:管"节点是否出现在产物";R23 管"出现了但尺寸造假"(1px 锚点正是骗 R21 的产物)
## Rule-Scan 提示要点
- 提示 UI sub-agent:写死 px 的元素先算 `bbox × scale`,勿目测;不确定尺寸来源时选择不写 px(布局驱动),而不是写个大概值
// R03 implicit-image(v1.2.3 软→硬迁移,极保守)
// 触发: 无任何前缀 + 子树纯几何/容器 + 无 TEXT/INSTANCE/COMPONENT + 无 btn-/input-/sub-/block- 子节点
// 且子树含 ≥3 个「真矢量路径」(VECTOR/BOOLEAN_OPERATION/STAR/REGULAR_POLYGON,CSS 难还原) → 该整体切图
// 期望: assets.txt 有切图记录 且 产物引用(<img>/background url)
// 保守: RECTANGLE/ELLIPSE/LINE 等可 CSS 化的简单形状不计入「必切」信号;阈值 ≥3 真矢量;
// 只抓「一堆真实矢量路径堆叠却没切图」,避免对装饰圆点/单形状/可 CSS 化图形误判。
import fs from 'node:fs';
import path from 'node:path';
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R03';
export const name = 'implicit-image';
const PREFIXES = ['img-', 'bg-', 'bgc-', 'x-', 'input-', 'sub-', 'block-', 'btn-', 'fixed-', 'end-', 'scrollx-', 'scrolly-'];
const GEOM = new Set(['VECTOR', 'BOOLEAN_OPERATION', 'RECTANGLE', 'ELLIPSE', 'STAR', 'REGULAR_POLYGON', 'LINE']);
const HARD_VECTOR = new Set(['VECTOR', 'BOOLEAN_OPERATION', 'STAR', 'REGULAR_POLYGON']);
const CONTAINER = new Set(['GROUP', 'FRAME']);
const DISQUALIFY_TYPE = new Set(['TEXT', 'INSTANCE', 'COMPONENT', 'COMPONENT_SET']);
const DISQUALIFY_PREFIX = ['btn-', 'input-', 'sub-', 'block-'];
const HARD_VECTOR_THRESHOLD = 3;
export function check({ cache, product, classMap }) {
const violations = [];
const assetsText = readAssets(product);
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
if (hasPrefix(node.name)) continue;
if (!Array.isArray(node.children) || node.children.length === 0) continue; // 叶子不判
const stat = scanSubtree(node);
if (!stat.ok) continue; // 含 TEXT/交互/复合前缀/非几何 → 不判
if (stat.hardVectorCount < HARD_VECTOR_THRESHOLD) continue; // 极保守阈值
const inAssets = assetsText.includes(nodeId);
const productRef = mentionsAsset(product, nodeId, classMap);
if (!inAssets && !productRef) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `整体切图(子树含 ${stat.hardVectorCount} 个矢量路径,CSS 难还原) + 产物引用`,
actual: 'assets.txt 无切图记录 且 产物未引用该 nodeId 的 img / background url',
file: '(missing)',
line: 0,
snippet: '',
});
}
}
return violations;
}
function hasPrefix(name) {
if (!name || typeof name !== 'string') return false;
const n = name.trim();
return PREFIXES.some((p) => n.startsWith(p));
}
// 遍历子树:命中 disqualify 立即 ok=false;统计真矢量数
function scanSubtree(root) {
let hardVectorCount = 0;
let ok = true;
const stack = [...(root.children || [])];
while (stack.length) {
const c = stack.shift();
if (!c || typeof c !== 'object') continue;
if (DISQUALIFY_TYPE.has(c.type)) { ok = false; break; }
if (typeof c.name === 'string' && DISQUALIFY_PREFIX.some((p) => c.name.startsWith(p))) { ok = false; break; }
if (c.type && !GEOM.has(c.type) && !CONTAINER.has(c.type)) { ok = false; break; } // 非几何/容器 → 不判
if (HARD_VECTOR.has(c.type)) hardVectorCount++;
if (Array.isArray(c.children)) stack.push(...c.children);
}
return { ok, hardVectorCount };
}
function readAssets(product) {
try {
const p = path.join(product.root, 'assets.txt');
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
} catch {
return '';
}
}
function mentionsAsset(product, nodeId, classMap) {
const idNorm = nodeId.replace(/:/g, '-');
for (const j of product.jsx || []) {
if (j.content.includes(nodeId) || j.content.includes(idNorm)) return true;
}
const classes = classMap[nodeId] || [];
for (const cls of classes) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, cls)) {
if (/url\(/i.test(r.body) || /background-image\s*:/i.test(r.body)) return true;
}
}
}
for (const s of product.style) {
if (s.content.includes(nodeId) || s.content.includes(idNorm)) return true;
}
return false;
}
// R04 text-gradient(v1.2.3 软→硬迁移)
// 触发: TEXT 节点,fills 非空,末位可见 fill 是 GRADIENT_*/IMAGE
// 期望: 对应 CSS 类含 background-clip: text(或 -webkit-background-clip: text)
// —— 渐变/图案字必须走 background-clip:text 方案,不能用 solid color 冒充
// 排斥: 末位可见 fill 是 SOLID → 归 R06;baked/hidden/templateDup 跳过;无 className 交 R21
// 保守: 只在「末位 fill 确为 GRADIENT/IMAGE 且 CSS 完全没有 background-clip:text」时报,
// gradient 具体色值/角度不校验(避免格式差异误判)。与 R06 同一套 TEXT-fills 判定机制。
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R04';
export const name = 'text-gradient';
const GRADIENT_TYPES = new Set([
'GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR', 'GRADIENT_DIAMOND', 'IMAGE',
]);
export function check({ cache, product, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node.type !== 'TEXT') continue;
if (!Array.isArray(node.fills) || node.fills.length === 0) continue;
if (node._inBakedSubtree) continue; // 文字像素已烤进父层 PNG,禁 DOM 交 R17
if (node._hidden) continue;
if (node._templateDup) continue; // .map() 数据副本,只校验代表项
const lastVisible = pickLastVisibleFill(node.fills);
if (!lastVisible) continue; // 全 invisible
if (!GRADIENT_TYPES.has(lastVisible.type)) continue; // SOLID → R06;其余类型不判
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue; // 不可追溯 → R21 统一报,避免双报
let hasClipText = false;
let hitFile = null;
let hitLine = 0;
let hitSnippet = '';
for (const cls of classes) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, cls)) {
if (/(?:-webkit-)?background-clip\s*:\s*text/i.test(r.body)) {
hasClipText = true;
break;
}
if (!hitFile) { hitFile = s.rel; hitLine = r.line; hitSnippet = r.body.slice(0, 200); }
}
if (hasClipText) break;
}
if (hasClipText) break;
}
if (!hasClipText) {
const kind = lastVisible.type === 'IMAGE' ? '图案(IMAGE)' : `渐变(${lastVisible.type})`;
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `css 含 background-clip: text(源自 fills 末位可见 ${kind})`,
actual: hitFile
? 'css 未含 background-clip: text(疑用 solid color 冒充渐变/图案字)'
: 'css 未找到该 class 规则体',
file: hitFile || '(missing in style)',
line: hitLine,
snippet: hitSnippet,
});
}
}
return violations;
}
function pickLastVisibleFill(fills) {
for (let i = fills.length - 1; i >= 0; i--) {
const f = fills[i];
if (f && f.visible !== false) return f;
}
return null;
}
// R09 btn-bgc-取值(v1.2.3 软→硬迁移)
// 触发: btn- 节点子树含 bgc- 子层,且 bgc- 末位可见 fill 是 GRADIENT_*
// 期望: btn-(或 bgc-)对应 CSS background 用 gradient 形态(linear/radial/conic-gradient)
// 保守: 只判「该有 gradient 却是 solid/缺失」,不校验具体色值/角度(避免格式误判);
// bgc 末位 SOLID → 按 background-color 取,不算 R09;bgc IMAGE → 归 R02。
// gradient 形态出现在 btn 或 bgc 任一 class 即通过,避免「bgc 渲染成子 div」误判。
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R09';
export const name = 'btn-bgc-取值';
const GRAD = new Set(['GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR', 'GRADIENT_DIAMOND']);
export function check({ cache, product, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node.name || !node.name.startsWith('btn-')) continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const bgc = findBgcDescendant(node);
if (!bgc) continue;
const last = pickLastVisibleFill(bgc.fills);
if (!last || !GRAD.has(last.type)) continue; // 只在 bgc 末位可见是 GRADIENT 时判
// 收集 btn + bgc 两处 class(background 可能写在任一处)
const classes = [...(classMap[nodeId] || []), ...(bgc.id ? classMap[bgc.id] || [] : [])];
if (classes.length === 0) continue; // 不可追溯 → R21
let hasGradient = false;
let hasSolidBg = false;
let hitFile = null;
let hitLine = 0;
let hitSnippet = '';
for (const cls of classes) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, cls)) {
if (/\b(?:linear|radial|conic)-gradient\s*\(/i.test(r.body)) { hasGradient = true; break; }
if (/background(?:-color)?\s*:\s*(?:#|rgb|hsl)/i.test(r.body)) hasSolidBg = true;
if (!hitFile) { hitFile = s.rel; hitLine = r.line; hitSnippet = r.body.slice(0, 200); }
}
if (hasGradient) break;
}
if (hasGradient) break;
}
if (!hasGradient) {
violations.push({
rule: id,
nodeId,
name: node.name,
type: node.type,
expected: `css background 用 gradient(取自 bgc- 子层末位 ${last.type})`,
actual: hasSolidBg
? 'css background 是 solid color(疑用 solid 冒充渐变)'
: 'css 未含 gradient 形态',
file: hitFile || '(missing in style)',
line: hitLine,
snippet: hitSnippet,
});
}
}
return violations;
}
function findBgcDescendant(node) {
if (!Array.isArray(node.children)) return null;
const stack = [...node.children];
while (stack.length) {
const c = stack.shift();
if (!c || typeof c !== 'object') continue;
if (typeof c.name === 'string' && c.name.startsWith('bgc-')) return c;
if (Array.isArray(c.children)) stack.push(...c.children);
}
return null;
}
function pickLastVisibleFill(fills) {
if (!Array.isArray(fills)) return null;
for (let i = fills.length - 1; i >= 0; i--) {
const f = fills[i];
if (f && f.visible !== false) return f;
}
return null;
}
// R12 flat-mode-naming(v1.2.3 软→硬迁移)
// 触发: config.merge.mode === 'flat'(所有 block 产物合并到一个文件)
// 期望: 同一 className 不被多个顶层规则体重复定义(合并后互相覆盖)
// 保守: 只统计「纯 .class {」顶层选择器,不含 .class:hover / .a .class / .class.x 等修饰形态;
// config 无 merge.mode 或 ≠ flat → 直接放行(安全降级)。
export const id = 'R12';
export const name = 'flat-mode-naming';
export function check({ product, config }) {
if (!config || !config.merge || config.merge.mode !== 'flat') return [];
const counts = new Map(); // class -> [{ rel, line }]
const re = /(?:^|[\s}])\.([a-zA-Z_][\w-]*)\s*\{/g;
for (const s of product.style) {
let m;
while ((m = re.exec(s.content)) !== null) {
const cls = m[1];
const line = s.content.slice(0, m.index).split('\n').length;
if (!counts.has(cls)) counts.set(cls, []);
counts.get(cls).push({ rel: s.rel, line });
}
re.lastIndex = 0;
}
const violations = [];
for (const [cls, occ] of counts) {
if (occ.length >= 2) {
violations.push({
rule: id,
nodeId: '(n/a)',
name: `.${cls}`,
type: 'CSS',
expected: `flat 模式下 className 唯一;.${cls} 应带 block 前缀区分(如 .topbar${cap(cls)})`,
actual: `.${cls} 被定义 ${occ.length} 次(合并后互相覆盖): ${occ.map((o) => `${o.rel}:${o.line}`).join(', ')}`,
file: occ[0].rel,
line: occ[0].line,
snippet: '',
});
}
}
return violations;
}
function cap(s) {
return s ? s[0].toUpperCase() + s.slice(1) : s;
}
// R14 fixed-z-index(v1.2.3 软→硬迁移)
// 触发: ≥2 个 fixed- 节点(可追溯、非 baked/hidden)
// 期望: 各 fixed 有 z-index 且不全相同(层级可区分)
// 保守: 只报「全部缺 z-index」或「全部 z-index 相同」这两种铁定覆盖的情形;
// 不强求具体递增序/具体值(那有合理变体,会误判)。单个 fixed → 不判。
import { findProperty } from '../lib/cssMatch.mjs';
export const id = 'R14';
export const name = 'fixed-z-index';
export function check({ cache, product, classMap }) {
const fixed = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node.name || !node.name.startsWith('fixed-')) continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue; // 不可追溯 → R21
fixed.push({ nodeId, node, classes });
}
if (fixed.length < 2) return []; // 单个/无 → 无层级冲突
const zvals = fixed.map((f) => {
const r = findProperty(product.style, f.classes, /z-index\s*:\s*-?\d+/i);
if (r.hit) {
const m = r.body.match(/z-index\s*:\s*(-?\d+)/i);
return { ...f, z: m ? m[1] : null, rel: r.rel, line: r.line };
}
return { ...f, z: null, rel: r.firstRel, line: r.firstLine };
});
const allMissing = zvals.every((v) => v.z === null);
const present = zvals.filter((v) => v.z !== null).map((v) => v.z);
const allSame = present.length === zvals.length && new Set(present).size === 1;
if (!allMissing && !allSame) return []; // 有区分 → 放行
const list = zvals.map((v) => `${v.node.name}=${v.z ?? '(缺)'}`).join(', ');
return [{
rule: id,
nodeId: zvals[0].nodeId,
name: zvals[0].node.name,
type: zvals[0].node.type,
expected: '多个 fixed- 元素 z-index 应存在且不全相同(层级可区分)',
actual: allMissing ? `全部 fixed- 未设 z-index: ${list}` : `全部 fixed- z-index 相同: ${list}`,
file: zvals[0].rel || '(style)',
line: zvals[0].line || 0,
snippet: '',
}];
}
// R22 empty-visual-btn(v1.2.4 新增,warning 级不阻断)
// 触发: btn- 节点在产物中存在(有 className),但自身与子树均无可见视觉——
// CSS 无 background/渐变、JSX 无 <img> 挂载、子树无可见 TEXT、bbox 面积 > 0
// → 空视觉按钮(透明热区)嫌疑(典型 test24 btn-qiang: cache 深度截断丢内容,产物只剩热区)。
// 保守: 仅 warning——部分设计确实用透明热区叠在整图上(bg- 父层已含按钮视觉),不能 exit 1;
// 但必须让主 agent 在 QA 段看见并复核(常见根因: cache 截断 / 该切图没切 / 漏画内容)。
// 跳过: baked / hidden / templateDup / 无 className。
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R22';
export const name = 'empty-visual-btn';
const VISUAL_CSS = /background(?:-image|-color)?\s*:|(?:linear|radial|conic)-gradient\s*\(|url\s*\(/i;
export function check({ cache, product, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
const nm = (node.name || '').trim();
if (!(nm.startsWith('btn-') || nm === 'btn')) continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const bbox = node.absoluteBoundingBox;
if (!bbox || !(bbox.width > 0 && bbox.height > 0)) continue;
if (!classMap[nodeId] || classMap[nodeId].length === 0) continue; // 不可追溯,交 R21
// 子树任一可见 TEXT → 文字按钮,有视觉
if (subtreeHasVisibleText(node)) continue;
// 自身或子树任一有 className 的节点,其 CSS 含背景/渐变/url → 有视觉
const ids = collectSubtreeIds(node, cache.nodes, nodeId);
if (ids.some((id2) => hasVisualCss(product.style, classMap[id2] || []))) continue;
// 子树任一节点在 JSX 中以 <img> 呈现,或标签上带内联背景(style={{backgroundImage}}) → 有视觉
if (ids.some((id2) => jsxHasImg(product.jsx, id2) || jsxHasInlineVisual(product.jsx, id2))) continue;
violations.push({
rule: id,
severity: 'warning',
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: 'btn- 节点应有可见视觉(文字/背景/图片);纯透明热区须人工确认是否叠在整图上',
actual: '产物 button 无文字、无 background、无 <img>,疑似空视觉按钮(常见根因: cache 深度截断 / 该切图没切)',
file: '(style)',
line: 0,
snippet: '',
});
}
return violations;
}
function subtreeHasVisibleText(root) {
let found = false;
const walk = (n) => {
if (found || !n || typeof n !== 'object') return;
if (n.visible === false) return;
if (n.type === 'TEXT' && String(n.characters || '').trim()) { found = true; return; }
for (const c of n.children || []) walk(c);
};
walk(root);
return found;
}
// btn 子树全部节点 id(含自身);以 cache.nodes 的 _parentId 链兜底,树上直接走 children
function collectSubtreeIds(root, cacheNodes, rootId) {
const ids = [];
const walk = (n) => {
if (!n || typeof n !== 'object') return;
if (n.id) ids.push(n.id);
for (const c of n.children || []) walk(c);
};
walk(root);
if (ids.length === 0) ids.push(rootId);
return ids;
}
function hasVisualCss(styleFiles, classes) {
for (const cls of classes) {
for (const s of styleFiles) {
for (const b of collectRuleBodies(s.content, cls)) {
if (VISUAL_CSS.test(b.body)) return true;
}
}
}
return false;
}
function jsxHasImg(jsxFiles, nodeId) {
const esc = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`<img[^>]*data-node-id=["']${esc}["']`, 'i');
return jsxFiles.some((f) => re.test(f.content));
}
// 切图消费契约允许 JSX 内联 style={{ backgroundImage: ... }} 挂图,同一标签内出现 background 即算有视觉
function jsxHasInlineVisual(jsxFiles, nodeId) {
const esc = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`<[a-zA-Z][^>]*data-node-id=["']${esc}["'][^>]*>`, 'i');
for (const f of jsxFiles) {
const m = f.content.match(re);
if (m && /background/i.test(m[0])) return true;
}
return false;
}
// R23 size-fidelity(v1.2.5 新增)
// 触发: 产物为节点显式声明了 px 宽/高,但与 cache bbox × scale 相差 > 4px。
// 特判: 1px×1px + overflow:hidden 且真实 bbox 面积远大于 1 → 「锚点欺诈」——
// 典型 test28 __screen-ref: 真实 331.5×141(应 663×282px)被写成 1×1 隐藏 div,
// 专为骗过 R02/R21 的存在性+引用检查(agent 自供"校验锚点")。
// 保守跳过(宁漏报不误判):
// - 未声明 px 宽/高(HUG 不写宽、FILL 写 100%、auto/fit-content) → 布局驱动,不判
// - TEXT 节点(字体渲染尺寸与 bbox 天然有出入) → 不判
// - 声明了 padding 且全部规则体均无 box-sizing: border-box → 盒模型不确定,不判
// - baked / hidden / templateDup / 无 className / 无 bbox → 不判
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R23';
export const name = 'size-fidelity';
const TOL = 4;
export function check({ cache, product, config, classMap }) {
const violations = [];
const scale = (config && config.unit && config.unit.scale) || 2;
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
if (node.type === 'TEXT') continue;
const bbox = node.absoluteBoundingBox;
if (!bbox || !(bbox.width > 0 && bbox.height > 0)) continue;
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue;
const bodies = allBodies(product.style, classes);
if (bodies.length === 0) continue;
const declW = lastPx(bodies, 'width');
const declH = lastPx(bodies, 'height');
if (declW == null && declH == null) continue; // 布局驱动尺寸,不判
const expW = Math.round(bbox.width * scale);
const expH = Math.round(bbox.height * scale);
// 锚点欺诈特判: 1×1 + overflow:hidden + 真实尺寸远大于 1
const hasOverflowHidden = bodies.some((b) => /overflow\s*:\s*hidden/i.test(b));
if (declW === 1 && declH === 1 && hasOverflowHidden && expW > 8 && expH > 8) {
violations.push(v(nodeId, node, `width:${expW}px height:${expH}px(bbox×${scale})`,
`1px×1px+overflow:hidden 锚点欺诈——DOM 仅为骗过存在性/引用检查而存在,视觉未渲染(真实 ${expW}×${expH}px)`));
continue;
}
// 盒模型不确定 → 保守跳过
const hasPadding = bodies.some((b) => /(?:^|[^-\w])padding(?:-\w+)?\s*:/i.test(b));
const hasBorderBox = bodies.some((b) => /box-sizing\s*:\s*border-box/i.test(b));
if (hasPadding && !hasBorderBox) continue;
const problems = [];
if (declW != null && Math.abs(declW - expW) > TOL) problems.push(`width=${declW}px 应 ${expW}px`);
if (declH != null && Math.abs(declH - expH) > TOL) problems.push(`height=${declH}px 应 ${expH}px`);
if (problems.length) {
violations.push(v(nodeId, node, `width≈${expW}px height≈${expH}px(bbox×${scale},容差 ${TOL}px)`, problems.join(';')));
}
}
return violations;
}
function v(nodeId, node, expected, actual) {
return {
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected,
actual,
file: '(style)',
line: 0,
snippet: '',
};
}
function allBodies(styleFiles, classes) {
const out = [];
for (const cls of classes) {
for (const s of styleFiles) {
for (const b of collectRuleBodies(s.content, cls)) out.push(b.body);
}
}
return out;
}
// 取该属性最后一次 px 声明(CSS 后写覆盖);无 px 声明(100%/auto/vw/未写)→ null
function lastPx(bodies, prop) {
let val = null;
const re = new RegExp(`(?:^|[^-\\w])${prop}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)px\\b`, 'gi');
for (const b of bodies) {
for (const m of b.matchAll(re)) val = parseFloat(m[1]);
}
return val;
}
# R22 - empty-visual-btn(v1.2.4 新增,warning 级)
## 判定归属
- **硬防线** (check-rules.mjs 自动识别): ✅(**warning 级**,提示不阻断、不 exit 1)
- **软防线** (Rule-Scan sub-agent 识别): ✅(生成前指引)
- **排斥条件**:
- 节点名非 `btn-` 前缀(或裸词 `btn`)→ 不适用
- `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过
- `classMap[nodeId]` 为空 → 不报,交 R21(不可追溯)
- 缺 `absoluteBoundingBox` 或面积为 0 → 跳过(不可见热区无视觉诉求)
## 触发条件
`btn-` 节点在产物中存在(有 className),但**自身与整棵子树都找不到任何可见视觉**——同时满足:
1. 子树无可见 TEXT(`visible !== false` 且 `characters` 非空白);
2. 自身与子树所有有 className 节点的 CSS 均无 `background` / `gradient` / `url(...)`;
3. 子树所有节点在 JSX 中均非 `<img>` 挂载,且标签上无内联 `style={{ background... }}`。
命中 → **warning**(不阻断)。
## 为什么是 warning 不是 error
部分设计确实用**透明热区**叠在整图上(`bg-` 父层已含按钮视觉),此时空视觉按钮是正确产物——机械判定无法区分"合法热区"与"内容丢失",按保守原则不 exit 1。但必须让主 agent 在 QA 段看见并**逐个复核**。
## 常见根因(复核清单)
1. **cache 深度截断**:`fetch-node --depth=N` 边界上的 GROUP children 为空,按钮真实内容不在 cache 里(典型 test24 btn-qiang 136:45810)→ 用 `figma.mjs fetch-node` 输出的 `truncatedSuspects` 核对,命中则不带 `--depth` 补拉子树后重生成。
2. **该切图没切**:内容是复杂矢量/图片组合,应命名 `btn-img-*`(可点击容器 + 内容为图片)走切图;只标 `btn-` 时生成器按 CSS 化处理,视觉丢失。
3. **合法透明热区**:视觉在 `bg-` 整图里 → 在 assets.txt 注明 `[R22 复核] {nodeId} 热区叠加于 {bg nodeId}`,消警。
## 期望产物
- 文字按钮:`<button>` + 子 TEXT `<span>` + 按 fills 写 `background`
- 图片按钮:改名 `btn-img-*` → 可点击容器 + 内容切图 `<img>` / `background-image`
- 透明热区(合法场景):产物不变,assets.txt 写复核记录
## 与相邻规则的边界
- **R09 btn-bgc**:管 `btn-` 内 `bgc-` 子层渐变取真值;R22 管"整个按钮什么视觉都没有"
- **R21 node-id-coverage**:btn- 节点没进产物由 R21 报;R22 只管"进了产物但没视觉"
- **R03 implicit-image**:管无前缀纯矢量堆;btn- 前缀节点被 R03 排斥,由 R22 接手
## Rule-Scan 提示要点
- 提示 UI sub-agent:btn- 子树若只有占位矩形 + 空 GROUP,先怀疑 cache 截断,再怀疑该切图没切;禁止静默生成透明热区不留痕
# R23 - size-fidelity(v1.2.5 新增)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅(生成前指引)
- **排斥条件**:
- 产物未显式声明 px 宽/高(HUG 不写宽、FILL 写 `100%`、`auto`/`fit-content`)→ 布局驱动,不判
- `TEXT` 节点(字体渲染尺寸与 bbox 天然有出入)→ 不判
- 声明了 padding 且全部规则体均无 `box-sizing: border-box` → 盒模型不确定,不判
- `_inBakedSubtree` / `_hidden` / `_templateDup` / 无 className / 无 bbox 或面积为 0 → 不判
## 触发条件
设 `scale` = `config.unit.scale`(默认 2),容差 4px:
- 期望 `width = bbox.width × scale`
- 期望 `height = bbox.height × scale`
**任一命中 → 违规**:
1. 产物声明的 px `width`/`height`(取同类规则体中最后一次声明,CSS 后写覆盖)与期望**相差 > 4px**
2. **锚点欺诈特判**:声明 `width: 1px; height: 1px` 且任一规则体含 `overflow: hidden`,而期望宽高均 > 8px → 直接点名"锚点欺诈"
## 为什么需要本规则
test28 实测:执行器把真实 331.5×141(应 663×282px)的节点写成 `1×1 + overflow:hidden` 隐藏 div——挂着 data-node-id 骗过 R21 存在性检查、塞 1px 背景图骗过 R02 引用检查,agent 自供这是"校验锚点"。当时**没有任何规则校验宽高忠实度**,1px 无人过问。本规则封死这个维度:产物敢写 px 数值,就必须与设计稿对得上。
## 期望产物
- 固定尺寸元素:`width/height = bbox × scale`(±4px)
- 布局驱动元素:不写死 px(HUG 不写宽 / FILL 写 `100%`),本规则自动跳过
- **禁止**用 1×1 隐藏锚点代替真实渲染;节点确实不该有独立视觉时,走 baked 机制(`bg-`/`img-` 前缀)或与用户确认后不出 DOM
## 与相邻规则的边界
- **R19 padding**:管盒内间距忠实度;R23 管盒本身尺寸
- **R20 absolute-position**:管坐标与 position 声明;R23 管宽高
- **R21 node-id-coverage**:管"节点是否出现在产物";R23 管"出现了但尺寸造假"(1px 锚点正是骗 R21 的产物)
## Rule-Scan 提示要点
- 提示 UI sub-agent:写死 px 的元素先算 `bbox × scale`,勿目测;不确定尺寸来源时选择不写 px(布局驱动),而不是写个大概值
+3
-2
{
"name": "@double-coding/pixel-print",
"version": "1.3.1",
"version": "1.4.0",
"description": "PixelPrint(像素打印)—— Figma D2C 工具,一键安装 Claude Code Skill,像素级还原设计稿为前端代码(H5 / React Native / xtaro)",

@@ -9,3 +9,4 @@ "bin": {

"scripts": {
"init": "node bin/install.js init"
"init": "node bin/install.js init",
"test": "node test/rules/run-all.mjs"
},

@@ -12,0 +13,0 @@ "files": [

+247
-9
#!/usr/bin/env node
// check-rules.mjs — pp-d2c 硬防线脚本 (v1.2.1)
// 覆盖 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21
// check-rules.mjs — pp-d2c 硬防线脚本 (v1.2.5)
// 覆盖 R01/R02/R03/R04/R05/R06/R08/R09/R12/R14/R16/R17/R18/R19/R20/R21/R23 + R22(warning)
// v1.2.5:(1) GATE-cache-truncation——合并 cache 中空 GROUP/BOOLEAN_OPERATION = depth 截断实锤,
// 截断 cache 会让逐节点对账真空通过(test29: 25 节点 cache 全防线失效);(2) R21 反向对账——
// 产物 data-node-id 必须存在于 cache(幻觉 id);(3) 新增 R23 size-fidelity——显式 px 宽高须
// ≈ bbox×scale,1×1+overflow:hidden 锚点欺诈点名(test28);(4) GATE-rule-hits 收紧——fallback
// 占位须伴随 assets.txt [Rule-Scan 降级] 记录;(5) GATE-slice-confirm——manifest confirmed
// 须为 true(figma.mjs confirm-slices 留痕,legacy 缺字段仅 warning)。
// v1.2.4:(1) --block 局部化——--root <nodeId> 或从产物 data-node-id 推断(LCA),cache 裁剪到
// block 子树,消除 R21/R03 对 block 外节点的全量误报;(2) GATE-rule-hits 门禁——rule-hits.json
// 缺失即 exit 1(含 assets.txt 消费证明捏造检测);(3) IMG-reconcile 三方对账(--merge)——产物
// 图片引用必须来自 slice-manifest;(4) R20 增强 position:absolute 声明强制;(5) 新增 R22
// empty-visual-btn(warning 级);规则可返回 severity:'warning' 进 warnings 不阻断。
// v1.2.3 软→硬迁移:原 Rule-Scan 软防线中机械可判的 5 条下沉硬防线,逐节点对账不依赖 sub- 触发——
// R03 implicit-image(≥3 真矢量路径无切图) / R04 text-gradient(末位 GRADIENT/IMAGE 须 background-clip:text) /
// R09 btn-bgc(bgc 渐变须落 gradient) / R12 flat-mode-naming(flat 同名类冲突) / R14 fixed-z-index(多 fixed z 层级)。
// R07/R10/R11/R13/R15 需 LLM 语义判定,仍留软防线。所有新规则一律保守(宁漏报不误判,边界 skip)。
// v1.2.0 对账升级:loadCache 标注 _inBakedSubtree / _hidden / _templateDup;

@@ -21,3 +36,4 @@ // R02/R06 跳过 baked·隐藏·模板副本 + SCSS &__ 嵌套匹配(lib/cssMatch.mjs)消除假阳性;

import path from 'node:path';
import { findProjectRoot, loadConfig, loadCache } from './lib/loadCache.mjs';
import fs from 'node:fs';
import { findProjectRoot, loadConfig, loadCache, inferBlockRoot, pruneToSubtree, findCacheTruncation } from './lib/loadCache.mjs';
import { loadProduct } from './lib/loadProduct.mjs';

@@ -29,5 +45,10 @@ import { buildNodeIdToClassName } from './lib/nodeIdToClassName.mjs';

import * as R02 from './rules/R02-fills-image.mjs';
import * as R03 from './rules/R03-implicit-image.mjs';
import * as R04 from './rules/R04-text-gradient.mjs';
import * as R05 from './rules/R05-space-between.mjs';
import * as R06 from './rules/R06-text-solid-last.mjs';
import * as R08 from './rules/R08-bg-landing-form.mjs';
import * as R09 from './rules/R09-btn-bgc.mjs';
import * as R12 from './rules/R12-flat-mode-naming.mjs';
import * as R14 from './rules/R14-fixed-z-index.mjs';
import * as R16 from './rules/R16-no-flatten-text.mjs';

@@ -39,7 +60,166 @@ import * as R17 from './rules/R17-no-baked-dom.mjs';

import * as R21 from './rules/R21-node-id-coverage.mjs';
import * as R22 from './rules/R22-empty-visual-btn.mjs';
import * as R23 from './rules/R23-size-fidelity.mjs';
const ALL_RULES = [R01, R02, R05, R06, R08, R16, R17, R18, R19, R20, R21];
const ALL_RULES = [R01, R02, R03, R04, R05, R06, R08, R09, R12, R14, R16, R17, R18, R19, R20, R21, R22, R23];
// ── rule-hits 存在性门禁(v1.2.4,问题5) ─────────────────────────
// Rule-Scan 是步骤 3.5 硬性动作;v1.2.2 起无 sub- 页面也必须对页面根跑一次(虚拟 block)。
// test24-27 实测: agent 跳过 Rule-Scan 并在 assets.txt 捏造"§3.5 允许合并到 UI 侧"许可
// → 文本约束拦不住,此处机械兜底。缺失 = violation(exit 1);降级占位(v0.3.21-fallback)算存在。
function checkRuleHitsGate(mode, productDir) {
const violations = [];
const need = [];
if (mode === 'block') {
need.push({ dir: productDir, label: `block ${path.basename(productDir)}` });
} else {
const blocksDir = path.join(productDir, 'blocks');
let blockDirs = [];
if (fs.existsSync(blocksDir)) {
blockDirs = fs.readdirSync(blocksDir)
.map((d) => path.join(blocksDir, d))
.filter((p) => {
try {
return fs.statSync(p).isDirectory() && fs.readdirSync(p).some((f) => /\.(jsx|tsx)$/.test(f));
} catch { return false; }
});
}
if (blockDirs.length > 0) for (const d of blockDirs) need.push({ dir: d, label: `block ${path.basename(d)}` });
else need.push({ dir: productDir, label: '页面根(无 sub-,v1.2.2 虚拟 block)' });
}
for (const { dir, label } of need) {
const f = path.join(dir, 'rule-hits.json');
if (fs.existsSync(f)) {
let parsed = null;
try { parsed = JSON.parse(fs.readFileSync(f, 'utf8')); } catch {
violations.push(gateViolation(label, f, 'rule-hits.json 存在但不是合法 JSON'));
continue;
}
// v1.2.5 收紧: fallback 占位仅限「Rule-Scan 真实二次失败」——必须伴随 assets.txt 的
// [Rule-Scan 降级] 记录(含失败原因)。无记录 = 用占位绕门禁(典型 test29:
// rule-hits 写 fallback 占位,assets.txt 却写"Rule-Scan 降级: 无",自相矛盾)。
const gb = String((parsed && parsed.generated_by) || '');
if (/fallback/i.test(gb)) {
let degraded = false;
const at2 = path.join(dir, 'assets.txt');
try {
degraded = fs.existsSync(at2) && fs.readFileSync(at2, 'utf8').includes('[Rule-Scan 降级]');
} catch { /* 读不到按无记录处理 */ }
if (!degraded) {
violations.push(gateViolation(label, f, `rule-hits 为 fallback 占位(${gb}),但 assets.txt 无 [Rule-Scan 降级] 失败记录——占位仅限真实二次派发失败,疑似用占位绕过 Rule-Scan`));
}
}
continue;
}
// 防捏造: 文件缺失但 assets.txt 已写消费证明
let fabricated = '';
const at = path.join(dir, 'assets.txt');
try {
if (fs.existsSync(at) && fs.readFileSync(at, 'utf8').includes('rule-hits 消费证明')) {
fabricated = ';且 assets.txt 已写"rule-hits 消费证明"(疑似捏造,文件并不存在)';
}
} catch { /* assets 不可读不影响门禁本身 */ }
violations.push(gateViolation(label, f, `缺失 ${f}${fabricated}`));
}
return violations;
}
function gateViolation(label, file, actual) {
return {
rule: 'GATE-rule-hits',
nodeId: '-',
name: label,
type: 'GATE',
expected: `${label} 必须存在 rule-hits.json(步骤 3.5 Rule-Scan 落盘;二次降级也须写 v0.3.21-fallback 占位)`,
actual,
file,
line: 0,
snippet: '',
};
}
// ── 切图三方对账(v1.2.4,问题3;--merge 时执行) ───────────────────
// 产物图片引用必须来自 slice-manifest(步骤 2.6 只消费清单契约):
// 产物引用 ∉ manifest → violation(疑似绕清单手工切图);
// manifest 条目未被引用 → warning(可能隐藏层/被裁,不阻断)。
// manifest 缺失 → warning 跳过(旧项目/无图页面不硬卡)。assets.txt 侧对账留给主 agent §6 文本层。
function checkImageReconciliation(projectRoot, cacheKey, product) {
const violations = [];
const warnings = [];
const cacheDir = path.join(projectRoot, '.d2c-cache', cacheKey);
let manifestFiles = [];
try {
manifestFiles = fs.readdirSync(cacheDir).filter((f) => /^slice-manifest-.*\.json$/.test(f));
} catch { /* cache 目录不可读走缺失分支 */ }
if (manifestFiles.length === 0) {
warnings.push({ rule: 'IMG-reconcile', reason: '未找到 slice-manifest-*.json,跳过三方对账' });
return { violations, warnings };
}
const entries = new Set();
for (const mf of manifestFiles) {
try {
const m = JSON.parse(fs.readFileSync(path.join(cacheDir, mf), 'utf8'));
for (const t of m.themes || []) {
for (const e of t.entries || []) entries.add(e.filename);
// 切图确认留痕(v1.2.5,GATE-slice-confirm): reskin-slice 落盘 confirmed:false,
// 用户确认后由 figma.mjs confirm-slices 翻 true;false = 未经确认就走到了合并阶段。
// 字段缺失(v1.2.5 前的 legacy manifest) → warning 不阻断。
if (t.confirmed === false) {
violations.push({
rule: 'GATE-slice-confirm',
nodeId: '-',
name: `${mf}#${t.slug || ''}`,
type: 'GATE',
expected: '步骤 2.6 切图确认暂停后,须经用户确认并执行 figma.mjs confirm-slices 将 manifest confirmed 置 true,再进入生成',
actual: 'manifest confirmed=false——切图结果未经用户确认(口头"别问了"不豁免;跳过确认的唯一通道是 config slice.confirmBeforeContinue=false,该配置下 reskin-slice 直接落 confirmed=true)',
file: path.join(cacheDir, mf),
line: 0,
snippet: '',
});
} else if (t.confirmed === undefined) {
warnings.push({ rule: 'GATE-slice-confirm', reason: `${mf}#${t.slug || ''} 无 confirmed 字段(legacy manifest,建议重跑 reskin-slice)` });
}
}
} catch { warnings.push({ rule: 'IMG-reconcile', reason: `${mf} 解析失败,已跳过` }); }
}
const refRe = /([\w./-]+\.(?:png|jpe?g|webp|svg|gif))/gi;
const refs = new Set();
for (const f of [...product.jsx, ...product.style]) {
for (const m of f.content.matchAll(refRe)) refs.add(path.posix.basename(m[1]));
}
// 保守匹配: JSX 动态拼接(如 \`\${x}__bg.png\`)会让正则只捕到文件名尾部碎片;
// manifest 任一条目以该碎片结尾即视为已消费,宁漏报不误判。
const matchedEntries = new Set();
const refConsumed = (r) => {
if (entries.has(r)) { matchedEntries.add(r); return true; }
let hit = false;
for (const e of entries) {
if (e.endsWith(r)) { matchedEntries.add(e); hit = true; }
}
return hit;
};
for (const r of refs) {
if (!refConsumed(r)) {
violations.push({
rule: 'IMG-reconcile',
nodeId: '-',
name: r,
type: 'IMG',
expected: '产物引用的切图必须来自 slice-manifest(步骤 2.6 只消费清单契约)',
actual: `产物引用 ${r} 不在任何 slice-manifest 中(疑似绕清单手工切图)`,
file: '(product)',
line: 0,
snippet: '',
});
}
}
const unused = [...entries].filter((k) => !matchedEntries.has(k));
if (unused.length) {
warnings.push({ rule: 'IMG-reconcile', reason: `manifest 中 ${unused.length} 张切图未被产物引用: ${unused.slice(0, 10).join(', ')}${unused.length > 10 ? ' …' : ''}` });
}
return { violations, warnings };
}
function parseArgv(argv) {
const args = { mode: null, dir: null, cacheKey: null, forceSkip: [] };
const args = { mode: null, dir: null, cacheKey: null, root: null, forceSkip: [] };
for (let i = 2; i < argv.length; i++) {

@@ -50,2 +230,3 @@ const a = argv[i];

else if (a === '--cache-key') { args.cacheKey = argv[++i]; }
else if (a === '--root') { args.root = argv[++i]; }
else if (a === '--force-skip') {

@@ -62,10 +243,14 @@ args.forceSkip = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);

function printHelp() {
process.stdout.write(`check-rules.mjs (pp-d2c v1.2.1)
process.stdout.write(`check-rules.mjs (pp-d2c v1.2.5)
Usage:
node check-rules.mjs --block <blockDir> --cache-key <fileKey>
node check-rules.mjs --block <blockDir> --cache-key <fileKey> [--root <nodeId>]
node check-rules.mjs --merge <pageDir> --cache-key <fileKey>
node check-rules.mjs --block <blockDir> --cache-key <fileKey> --force-skip R05,R06
Rules covered: R01 R02 R05 R06 R08 R16 R17 R18 R19 R20 R21
--root: block 子树根 nodeId(局部化对账范围);缺省时 --block 模式自动从产物 data-node-id 推断(LCA)
Rules covered: R01 R02 R03 R04 R05 R06 R08 R09 R12 R14 R16 R17 R18 R19 R20 R21 R23 R22(warn)
Gates: GATE-cache-truncation(cache 完整性) GATE-rule-hits(存在性+fallback 收紧)
IMG-reconcile(--merge 三方对账) GATE-slice-confirm(--merge 切图确认留痕)
Exit: 0=ok, 1=violations, 2=env-error

@@ -107,2 +292,51 @@ `);

// --block 局部化(v1.2.4): cache 装载的是 fileKey 全量,block 产物只覆盖本子树,
// 必须裁剪到 block 根,否则 R21/R03 等把 block 外节点全部误报。
// 根来源: --root 显式指定 > 从产物 data-node-id 推断(LCA); merge 模式默认全量。
if (args.root && !cache.nodes[args.root]) {
fatal(`--root ${args.root} not found in cache`);
}
let scopeRoot = args.root;
if (!scopeRoot && args.mode === 'block') {
scopeRoot = inferBlockRoot(cache.nodes, classMap);
if (!scopeRoot) {
warnings.push({ rule: 'scope', reason: '--block 无法从产物 data-node-id 推断子树根,退回全量 cache 对账(可能出现 block 外误报,建议显式 --root)' });
}
}
if (scopeRoot) {
const before = Object.keys(cache.nodes).length;
cache.nodes = pruneToSubtree(cache.nodes, scopeRoot);
warnings.push({ rule: 'scope', reason: `对账范围=子树 ${scopeRoot}(${args.root ? '--root' : '产物推断'}), cache ${before}→${Object.keys(cache.nodes).length} 节点` });
}
// cache 完整性门禁(v1.2.5,GATE-cache-truncation): 截断 cache 会让逐节点对账真空通过,
// 必须先于一切规则拦截——空 GROUP/BOOLEAN_OPERATION = fetch depth 截断实锤。
const trunc = findCacheTruncation(cache.nodes);
for (const t of trunc.hard) {
violations.push({
rule: 'GATE-cache-truncation',
nodeId: t.nodeId,
name: t.name,
type: t.type,
expected: 'GROUP/BOOLEAN_OPERATION 在 Figma 中必有子节点;cache 中为空 = fetch-node depth 截断,该子树内容缺失',
actual: '空容器(不带 --depth 重拉该子树后重新生成与对账;凭截断 cache 出码必然丢内容)',
file: '(cache)',
line: 0,
snippet: '',
});
}
for (const t of trunc.soft) {
warnings.push({ rule: 'GATE-cache-truncation', reason: `${t.name}(${t.nodeId}) ${t.type} children 为空,疑似截断` });
}
// rule-hits 存在性门禁(v1.2.4): Rule-Scan 未跑 → 直接违规,不看规则结果
for (const v of checkRuleHitsGate(args.mode, productDir)) violations.push(v);
// 切图三方对账(v1.2.4): 合并阶段核对产物图片引用 ↔ slice-manifest
if (args.mode === 'merge') {
const rec = checkImageReconciliation(projectRoot, args.cacheKey, product);
for (const v of rec.violations) violations.push(v);
for (const w of rec.warnings) warnings.push(w);
}
for (const rule of ALL_RULES) {

@@ -117,3 +351,7 @@ checked.push(rule.id);

const hits = rule.check({ cache, product, config, classMap });
for (const h of hits) violations.push(h);
// severity=warning 的命中(如 R22)进 warnings 不阻断;其余进 violations
for (const h of hits) {
if (h.severity === 'warning') warnings.push({ rule: h.rule, reason: `${h.name}(${h.nodeId}): ${h.actual}`, detail: h });
else violations.push(h);
}
} catch (e) {

@@ -120,0 +358,0 @@ warnings.push({ rule: rule.id, reason: `rule crashed: ${e.message}` });

@@ -223,2 +223,22 @@ #!/usr/bin/env node

// 深度截断嫌疑检测: GROUP/BOOLEAN_OPERATION/INSTANCE/COMPONENT 在 Figma 中必有子节点,
// 位于 depth 边界却 children 为空 → 子树被 REST depth 参数截断,视觉内容不在 cache 里
// (典型 test24 btn-qiang 136:45810: depth=8 边界空 GROUP,按钮真实内容丢失 → 产物透明热区)。
// FRAME 可合法为空(占位盒),不纳入,避免误报。
const NEVER_EMPTY_TYPES = new Set(['GROUP', 'BOOLEAN_OPERATION', 'INSTANCE', 'COMPONENT'])
function findTruncatedSuspects(root, depth) {
if (!depth) return []
const suspects = []
const walk = (n, d) => {
if (!n || typeof n !== 'object') return
const empty = !Array.isArray(n.children) || n.children.length === 0
if (d >= depth && empty && NEVER_EMPTY_TYPES.has(n.type)) {
suspects.push({ id: n.id, name: n.name, type: n.type })
}
for (const c of n.children || []) walk(c, d + 1)
}
walk(root, 0)
return suspects
}
async function cmdFetchNode(positional, flags) {

@@ -240,4 +260,9 @@ const [fileKey, nodeId] = positional

const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
if (!depth || (cached._depth && cached._depth >= depth)) {
return output({ ok: true, data: { cached: true, node: cached.node } })
// 复用条件: 全量请求(无 --depth)只能复用全量 cache(_depth=null);
// 带 depth 请求可复用全量 cache 或深度不小于本次的 cache。
// 旧逻辑 !depth 即复用,会把深度截断的旧 cache 当全量用 → 子树静默丢失。
const cachedIsFull = cached._depth == null
if (cachedIsFull || (depth && cached._depth >= depth)) {
const truncatedSuspects = cachedIsFull ? [] : findTruncatedSuspects(cached.node, cached._depth)
return output({ ok: true, data: { cached: true, _depth: cached._depth, truncatedSuspects, node: cached.node } })
}

@@ -255,3 +280,4 @@ } catch { /* 缓存损坏,重拉 */ }

fs.writeFileSync(cacheFile, JSON.stringify({ _depth: depth || null, node: doc }, null, 2))
output({ ok: true, data: { cached: false, node: doc } })
const truncatedSuspects = depth ? findTruncatedSuspects(doc, depth) : []
output({ ok: true, data: { cached: false, _depth: depth || null, truncatedSuspects, node: doc } })
} catch (e) {

@@ -317,2 +343,26 @@ fail(e.message)

// ─── 命令: confirm-slices <fileKey> <slug> ──────────────────────
// v1.2.5 切图确认留痕: 步骤 2.6 用户确认切图结果后执行,把 slice-manifest 对应 theme 的
// confirmed 置 true(check-rules --merge 的 GATE-slice-confirm 依赖该字段)。
// 仅在用户明确确认后调用;agent 不得未经确认自行执行(留痕即取证,伪造可事后对会话审计)。
function cmdConfirmSlices(positional) {
const [fileKey, slug] = positional
if (!fileKey || !slug) return fail('用法: figma confirm-slices <fileKey> <slug>')
const { projectRoot } = loadConfig()
const manifestFile = path.join(projectRoot, '.d2c-cache', fileKey, `slice-manifest-${slug}.json`)
if (!fs.existsSync(manifestFile)) return fail(`清单不存在: ${manifestFile}`)
let manifest
try { manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')) } catch (e) {
return fail(`清单解析失败: ${e.message}`)
}
const themes = (manifest.themes || []).filter(t => t.slug === slug)
const targets = themes.length ? themes : (manifest.themes || [])
if (targets.length === 0) return fail(`清单中无 theme 可确认: ${manifestFile}`)
const confirmedAt = new Date().toISOString()
for (const t of targets) { t.confirmed = true; t.confirmedAt = confirmedAt }
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2))
output({ ok: true, data: { manifest: manifestFile, confirmed: targets.map(t => t.slug), confirmedAt } })
}
// ─── 命令: screenshot <fileKey> <nodeId> [--tag=leaf|whole|block] ─

@@ -370,2 +420,3 @@

'export-image': () => cmdExportImage(positional, flags),
'confirm-slices': () => cmdConfirmSlices(positional),
'screenshot': () => cmdScreenshot(positional, flags),

@@ -384,2 +435,3 @@ 'cleanup-tmp': () => cmdCleanupTmp(),

node figma.mjs export-image <fileKey> <nodeId> --filename=<name> [--format=png|svg] [--scale=1|2] [--preserve-effect]
node figma.mjs confirm-slices <fileKey> <slug> (步骤 2.6 用户确认切图后执行,manifest confirmed → true)
node figma.mjs screenshot <fileKey> <nodeId> [--tag=leaf|whole|block] [--scale=2]

@@ -386,0 +438,0 @@ node figma.mjs cleanup-tmp

@@ -83,2 +83,62 @@ import fs from 'node:fs';

// ── --block 局部化(v1.2.4)────────────────────────────────────
// loadCache 装载的是 fileKey 全量节点;--block 模式产物只覆盖本 block 子树,
// 不裁剪会让 R21/R03 等把 block 外所有应渲染节点误报(sub-agent 被迫解释"外部违规")。
// 从产物 data-node-id 集合推断 block 子树根:全部产物节点的最深公共祖先(LCA)。
export function inferBlockRoot(cacheNodes, classMap) {
const ids = Object.keys(classMap).filter((id) => cacheNodes[id]);
if (ids.length === 0) return null;
const chain = (id) => {
const arr = [];
let cur = id;
while (cur) { arr.push(cur); cur = cacheNodes[cur] ? cacheNodes[cur]._parentId : null; }
return arr; // 自身在前,根在后
};
let common = chain(ids[0]);
for (let i = 1; i < ids.length; i++) {
const set = new Set(chain(ids[i]));
common = common.filter((x) => set.has(x));
if (common.length === 0) return null;
}
return common[0] || null; // 最深公共祖先
}
// 把 cache.nodes 裁剪到 rootId 子树(含 rootId 自身)。rootId 的父不在集合内:
// R20 对 rootId 自身会因父 bbox 缺失保守跳过,块内子孙照常对账。
export function pruneToSubtree(cacheNodes, rootId) {
if (!rootId || !cacheNodes[rootId]) return cacheNodes;
const keep = {};
for (const [id, n] of Object.entries(cacheNodes)) {
let cur = id;
while (cur) {
if (cur === rootId) { keep[id] = n; break; }
cur = cacheNodes[cur] ? cacheNodes[cur]._parentId : null;
}
}
return keep;
}
// ── cache 完整性检测(v1.2.5)────────────────────────────────────
// GROUP/BOOLEAN_OPERATION 在 Figma 中必有子节点;合并全部分片后仍为空 = REST depth 截断实锤
// (深分片 walk 时会覆盖浅分片的同 id 节点对象,覆盖后仍空说明没有任何分片拉到其内容)。
// 典型 test29: cache 仅 _depth=1/2 分片、25 节点,逐节点对账因"无节点可对"真空通过。
// 跳过: baked(bg-/img-/x- 自身及子树,像素已烤进 PNG,子树内容与对账无关)/hidden/templateDup。
// INSTANCE/COMPONENT 为空极罕见但理论可构造 → 归 soft(调用方作 warning)。
const NEVER_EMPTY_HARD = new Set(['GROUP', 'BOOLEAN_OPERATION']);
const NEVER_EMPTY_SOFT = new Set(['INSTANCE', 'COMPONENT']);
export function findCacheTruncation(cacheNodes) {
const hard = [];
const soft = [];
for (const [nodeId, node] of Object.entries(cacheNodes)) {
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
if (isNonRecursivePrefix(node.name)) continue; // 整体切图/忽略目标,子树内容不参与对账
if (Array.isArray(node.children) && node.children.length > 0) continue;
if (NEVER_EMPTY_HARD.has(node.type)) hard.push({ nodeId, name: node.name || '(no name)', type: node.type });
else if (NEVER_EMPTY_SOFT.has(node.type)) soft.push({ nodeId, name: node.name || '(no name)', type: node.type });
}
return { hard, soft };
}
function walk(node, acc, parentRealId, inBaked, bakedBy, hidden, templateDup) {

@@ -85,0 +145,0 @@ if (!node || typeof node !== 'object') return;

@@ -1,4 +0,6 @@

// R20 absolute-position(v1.2.0 对账新增)
// R20 absolute-position(v1.2.0 对账新增;v1.2.4 增强 position 声明强制)
// 触发: node.layoutPositioning === 'ABSOLUTE'(脱离父 autolayout 顺流,绝对定位)
// 期望: CSS top ≈ (子.bbox.y − 父.bbox.y) × scale;left ≈ (子.bbox.x − 父.bbox.x) × scale(容差 4px)
// 期望: CSS 必须声明 position: absolute(top/left 为 0 可省数值,position 不可省——
// relative/static 仍占父 flex 流位挤压兄弟,典型 test27 211:435 main__screen);
// CSS top ≈ (子.bbox.y − 父.bbox.y) × scale;left ≈ (子.bbox.x − 父.bbox.x) × scale(容差 4px)
// 违反: 坐标靠猜(典型 test13 img-huochepiao:真值 left≈-10/top≈-13 溢出到背景上,产物却写 top:40/left:40)

@@ -44,5 +46,9 @@ // 跳过: baked / hidden / templateDup / 无 className / 父无 bbox

// 期望值≈0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,容忍不报(避免噪声)。
// 期望值≈0 且产物未显式声明 top/left → 原点绝对定位与顺流视觉等价,容忍不报(避免噪声)。
// 期望非 0 却缺失(丢了真实偏移)、或写了值但对不上(如 huochepiao 40 vs -13)→ 报。
const problems = [];
// v1.2.4: position: absolute 声明本身不可省——检查该元素全部 class 的全部规则体
if (!anyBodyHas(product.style, classes, /position\s*:\s*absolute\b/i)) {
problems.push('缺 position: absolute(relative/static 仍参与父流布局,占位挤压兄弟)');
}
if (cssTop == null) {

@@ -73,2 +79,14 @@ if (Math.abs(expTop) > TOL) problems.push(`缺 top(应 ${expTop}px,丢了真实偏移)`);

// 该元素任一 class 的任一规则体命中 re 即真(position 可能声明在另一条同类规则里)
function anyBodyHas(styleFiles, classes, re) {
for (const cls of classes) {
for (const s of styleFiles) {
for (const b of collectRuleBodies(s.content, cls)) {
if (re.test(b.body)) return true;
}
}
}
return false;
}
function firstBody(styleFiles, classes) {

@@ -75,0 +93,0 @@ for (const cls of classes) {

@@ -20,2 +20,6 @@ // R21 node-id-coverage(v1.2.1 对账新增)

// 副本已被 _templateDup 跳过。
//
// v1.2.5 反向对账:产物 JSX 里每个字面量 data-node-id 必须存在于 cache——
// 不存在 = 幻觉 id(凭记忆/臆造挂 id 应付正向检查)。典型 test29:产物 33 个 id
// 有 11 个不在 cache(浅 cache + 低推理执行器编造)。表达式形式(data-node-id={x})不判。

@@ -52,2 +56,24 @@ export const id = 'R21';

// 反向对账(v1.2.5):产物字面量 data-node-id 必须来自 cache
const idRe = /data-node-id=["']([^"']+)["']/g;
const productIds = new Set();
for (const f of product.jsx) {
for (const m of f.content.matchAll(idRe)) productIds.add(m[1]);
}
for (const pid of productIds) {
if (!cache.nodes[pid]) {
violations.push({
rule: id,
nodeId: pid,
name: '(cache 中不存在)',
type: '?',
expected: '产物 data-node-id 必须来自 cache 真实节点(§5.1.1 铁律的反向:id 不能臆造)',
actual: 'cache 中不存在该 nodeId——幻觉 id,或该子树从未 fetch(凭记忆出码)',
file: '(jsx)',
line: 0,
snippet: '',
});
}
}
return violations;

@@ -54,0 +80,0 @@ }

@@ -5,4 +5,4 @@ # R03 - implicit-image

- **硬防线** (check-rules.mjs 自动拦截): ❌ (语义判断,脚本难)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**:

@@ -9,0 +9,0 @@ - 节点有 `img-` / `bg-` / `bgc-` / `x-` / `input-` / `sub-` / `block-` / `btn-` / `fixed-` / `end-` / `scrollx-` / `scrolly-` 前缀 → 不适用

@@ -5,4 +5,4 @@ # R04 - text-gradient

- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**:

@@ -9,0 +9,0 @@ - 末位可见 fill 是 SOLID → 归 R06

@@ -5,4 +5,4 @@ # R09 - btn-bgc-取值

- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**:

@@ -9,0 +9,0 @@ - `bgc-` 层的 fills 只有单层 SOLID → 直接按 CSS `background-color` 取,不算 R09

@@ -5,4 +5,4 @@ # R12 - flat-mode-naming

- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**:

@@ -9,0 +9,0 @@ - `pp-d2c.config.json` 的 `merge.mode !== 'flat'` → 不适用

@@ -5,4 +5,4 @@ # R14 - fixed-z-index

- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**: 页面只有 1 个 `fixed-` 节点 → 无冲突,不判 z-index

@@ -9,0 +9,0 @@

@@ -25,6 +25,7 @@ # R20 - absolute-position(v1.2.0 对账新增)

2. 期望值**非 0**(|exp| > 4)但产物**缺** `top`/`left` → 丢了真实偏移
3. 产物该元素**任一 class 的任一规则体**均未声明 `position: absolute` → 违规(**v1.2.4 新增**;relative/static 仍占父 flex 流位挤压兄弟,典型 test27 `main__screen` 写 `position: relative` 逃逸)
命中 → 违规。
> **容忍**:期望值 ≈ 0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,不报(避免噪声)。支持 `top: 0` 无单位零与 `inset` 简写。
> **容忍**:期望值 ≈ 0 且产物未显式声明 `top`/`left` → 原点绝对定位数值可省,不报(避免噪声);**但 `position: absolute` 声明本身不可省**(第 3 条)。支持 `top: 0` 无单位零与 `inset` 简写。

@@ -31,0 +32,0 @@ ## 期望产物

@@ -26,2 +26,4 @@ # R21 - node-id-coverage(v1.2.1 对账新增)

**反向对账(v1.2.5)**:产物 JSX 中每个**字面量** `data-node-id` 必须存在于 cache——cache 中不存在 = **幻觉 id**(凭记忆/臆造挂 id 应付正向检查),命中 → 违规。表达式形式(`data-node-id={x}`)不判。典型 test29:产物 33 个 id 有 11 个不在 cache(浅 cache + 低推理执行器编造)。
## 期望产物

@@ -28,0 +30,0 @@

@@ -32,4 +32,4 @@ # pp-d2c 规则库

| R02 | fills-image | 硬防线 | `fills[].some(f => f.type === 'IMAGE' && f.visible !== false)` |
| R03 | implicit-image | 软防线 | 无前缀 + 整棵子树 VECTOR/BOOL/几何 + 无 TEXT/INSTANCE + 无 btn-/input-/sub-/block- 子层 |
| R04 | text-gradient | 软防线 | `TEXT` 节点,fills 末位可见 = `GRADIENT_*` 或 `IMAGE` |
| R03 | implicit-image | 硬防线(v1.2.3) | 无前缀 + 整棵子树 VECTOR/BOOL/几何 + 无 TEXT/INSTANCE + 无 btn-/input-/sub-/block- 子层 |
| R04 | text-gradient | 硬防线(v1.2.3) | `TEXT` 节点,fills 末位可见 = `GRADIENT_*` 或 `IMAGE` |
| R05 | space-between | 硬防线 | `primaryAxisAlignItems === 'SPACE_BETWEEN'` |

@@ -39,8 +39,8 @@ | R06 | text-solid-last | 硬防线 | `TEXT` 节点,fills 末位可见 = `SOLID` |

| R08 | bg-landing-form | 硬防线 | `name.startsWith('bg-')` 或 `name === 'bg'`,产物落地形态错 |
| R09 | btn-bgc-取值 | 软防线 | `btn-` 前缀内含 `bgc-` 子层,bgc 的真 fills 是 GRADIENT/IMAGE |
| R09 | btn-bgc-取值 | 硬防线(v1.2.3) | `btn-` 前缀内含 `bgc-` 子层,bgc 的真 fills 是 GRADIENT/IMAGE |
| R10 | no-fake-solid-color | 软防线 | 产物 CSS 出现 `color: #XXX`,但 cache 里对应节点找不到源头 |
| R11 | mask-vector-css-able | 软防线 | 复合 mask / 多层 vector,CSS 表达不了 → 应切图 |
| R12 | flat-mode-naming | 软防线 | `merge.mode === 'flat'` 下类名跨 block 冲突 |
| R12 | flat-mode-naming | 硬防线(v1.2.3) | `merge.mode === 'flat'` 下类名跨 block 冲突 |
| R13 | unit-scale | 软防线 | Figma px → 产物 px 未换算(应 `outputBase / figmaBase`) |
| R14 | fixed-z-index | 软防线 | 多个 `fixed-` 节点,z-index 未递增 |
| R14 | fixed-z-index | 硬防线(v1.2.3) | 多个 `fixed-` 节点,z-index 未递增 |
| R15 | 同构 map 渲染 | 软防线 | 同层 ≥3 同构子节点,展开成重复代码而非 `.map()` |

@@ -51,11 +51,17 @@ | R16 | no-flatten-text | 硬防线 | GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT 且前缀非 `img-`/`bg-`,产物 jsx 出现 `<img data-node-id="该节点">` |

| R19 | padding | 硬防线 | autolayout 容器 padding 与 `Figma paddingT/R/B/L × scale` 不符(凭空加 / 漏写 / 数值错) |
| R20 | absolute-position | 硬防线 | `layoutPositioning === 'ABSOLUTE'`(非 fixed-),top/left ≠ (子bbox−父bbox)×scale |
| R20 | absolute-position | 硬防线 | `layoutPositioning === 'ABSOLUTE'`(非 fixed-),top/left ≠ (子bbox−父bbox)×scale;或产物未声明 `position: absolute`(v1.2.4) |
| R21 | node-id-coverage | 硬防线 | 应渲染节点(TEXT/autolayout 容器/ABSOLUTE/img-·btn-·input-)在产物 JSX 里找不到 data-node-id |
| R22 | empty-visual-btn | warning(v1.2.4) | btn- 子树无文字/背景/图,产物只剩透明热区(常见根因: cache 深度截断 / 该切图没切) |
| R23 | size-fidelity | 硬防线(v1.2.5) | 显式 px 宽高与 bbox×scale 相差 >4px;1×1+overflow:hidden 锚点欺诈点名 |
## 判定归属说明
**硬防线** (`check-rules.mjs` 自动拦截): 用代码 grep + JSON scan 精确判定,exit 1 拦截 → R01 / R02 / R05 / R06 / R08 / R16 / R17 / R18 / R19 / R20 / R21。
**硬防线 17 条** (`check-rules.mjs` 自动拦截): 用代码 grep + JSON scan 精确判定,exit 1 拦截 → R01 / R02 / R03 / R04 / R05 / R06 / R08 / R09 / R12 / R14 / R16 / R17 / R18 / R19 / R20 / R21(v1.2.5 起含反向对账:产物 data-node-id ∉ cache = 幻觉 id) / R23(v1.2.5)。
**软防线** (Rule-Scan sub-agent 识别): 需 LLM 语义判断,输出 `rule-hits.json` 给 UI sub-agent 参考 → R03 / R04 / R07 / R09 / R10 / R11 / R12 / R13 / R14 / R15。
**软防线** (Rule-Scan sub-agent 识别): 需 LLM 语义判断,输出 `rule-hits.json` 给 UI sub-agent 参考 → R07 / R10 / R11 / R13 / R15。(v1.2.3 起 R03/R04/R09/R12/R14 迁入硬防线)
**warning 级** (`check-rules.mjs` 提示不阻断): R22 empty-visual-btn。另有四道流程门禁: **GATE-cache-truncation**(v1.2.5)——合并 cache 中空 GROUP/BOOLEAN_OPERATION = depth 截断实锤,截断 cache 出码必丢内容;**GATE-rule-hits**(v1.2.4,v1.2.5 收紧)——rule-hits.json 缺失即 exit 1,fallback 占位必须伴随 assets.txt `[Rule-Scan 降级]` 记录;**IMG-reconcile**(v1.2.4,--merge)——产物图片引用必须来自 slice-manifest 三方对账;**GATE-slice-confirm**(v1.2.5,--merge)——manifest `confirmed` 须为 true(`figma.mjs confirm-slices` 用户确认留痕,legacy 缺字段仅 warning)。
**Rule-Scan 扫描范围** (v1.2.4 恢复全量): Rule-Scan 扫**全部规则**出 `rule-hits.json`——软防线 5 条以此为唯一判定点;硬防线命中作为生成前逐节点指引(判决权在 check-rules,指引漏扫不算违规)。
**v1.2.0 对账基座**: R02 / R06 / R17 / R18 / R19 / R20 依赖 `bin/lib/loadCache.mjs` 标注的 `_inBakedSubtree`(整体切图子树)/`_hidden`(隐藏)/`_templateDup`(`.map()` 数据副本),以及 `bin/lib/cssMatch.mjs` 的 SCSS `&__foo` 嵌套匹配。这些标注把"整体切图子树 / 隐藏 / 列表副本 / 嵌套写法"四类假阳性从根源清除,使硬防线报数即真值,校验从"黑名单抽查"升级为"以 cache 为真值逐节点对账"。

@@ -67,2 +73,4 @@

**派发时机**: 每个 `sub-` block 出码前各派一次;**页面无 sub- 时(v1.2.2)对整页派一次**——页面根视为虚拟 block,`rule-hits.json` 落页面根目录(与页面 `assets.txt` 同级)。软防线覆盖不依赖设计师是否标了 sub-。
派发时的完整 prompt:

@@ -74,3 +82,3 @@

任务:
1. Read templates/skills/pp-d2c/rules/*.md (全部 19 条)
1. Read templates/skills/pp-d2c/rules/*.md 全部规则(v1.2.4 恢复全量扫描)
2. Read .d2c-cache/<cache-key>/nodes/ 下与本 block nodeIds 相关的 JSON

@@ -81,4 +89,4 @@ 3. 对本 block 的每个节点, 判断命中了哪些规则

规则命中判定原则:
- 硬防线规则 (R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21): 你也扫,即使 check-rules.mjs 会兜底
- 软防线规则 (R03/R04/R07/R09-R15): 你是唯一识别方
- 硬防线规则 (R01/R02/R03/R04/R05/R06/R08/R09/R12/R14/R16/R17/R18/R19/R20/R21) 与 warning 级 R22: 必须扫出命中作为生成前逐节点指引(判决权在 check-rules.mjs,指引漏扫不算违规,但禁止整类跳过)
- 软防线规则 (R07/R10/R11/R13/R15): 你是唯一识别方
- 排斥条件: 若节点命中高优先级规则, 低优先级规则不再重复列

@@ -126,3 +134,3 @@ - 优先级 (由高到低): R21 > R16 > R17 > R02 > R01 > R05 > R11 > R03 > R04 > R07 > R06 > R09 > R08 > R20 > R18 > R19 > R14 > R15 > R13 > R12 > R10(R21 最高:节点不可追溯则其余绑定类规则无从谈起)

- **硬编码 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20 逻辑**,rules/*.md 是设计文档,不是执行文档
- **硬编码 R01/R02/R03/R04/R05/R06/R08/R09/R12/R14/R16/R17/R18/R19/R20/R21 逻辑**,rules/*.md 是设计文档,不是执行文档
- 假阳性时用 `--force-skip R0X,R0Y` 跳过,但 UI sub-agent 必须在 `assets.txt` 备注 `[脚本误判] R0X {nodeId} 理由: ...`

@@ -162,2 +170,6 @@ - 详细 CLI 见 `templates/skills/pp-d2c/bin/check-rules.mjs --help`

- **v1.2.5** 防线加固批(test28/29 取证):GATE-cache-truncation(cache 完整性);R21 反向对账(幻觉 id);R23 size-fidelity(尺寸忠实度+锚点欺诈);GATE-rule-hits 收紧(fallback 占位须有降级记录);GATE-slice-confirm(切图确认留痕);单 agent 执行模式(无 sub-agent 平台合法路径)
- **v1.2.4** 生成过程缺陷修复批(test24-27 取证):check-rules --block 局部化(--root/LCA 推断);GATE-rule-hits 门禁(缺失即 exit 1,含消费证明捏造检测);IMG-reconcile 三方对账;R20 强制 `position: absolute` 声明;新增 R22 empty-visual-btn(warning 级);Rule-Scan 恢复全量扫描出指引(判决权仍在 check-rules)
- **v1.2.3** 软→硬迁移:R03/R04/R09/R12/R14 从软防线下沉 check-rules 硬防线(机械可判、逐节点对账,不依赖 sub- 触发,exit 1 阻断);软防线剩 R07/R10/R11/R13/R15(需 LLM 语义);新硬规则一律保守(宁漏报不误判,边界 skip)
- **v1.2.2** Rule-Scan 触发与 sub- 解耦:执行清单 sub- block 数为 0 时,主 agent 出码前对整页跑一次 Rule-Scan(页面根为虚拟 block,`rule-hits.json` 落页面根目录);修复无 sub- 页面软规则 R03/R04/R07/R09-R15 完全不触发的覆盖空档
- **v1.2.1** `_inBakedSubtree` 移除 bgc-(bgc- 盒级 CSS 写父、非切图,子孙误放 TEXT 应被 R06/R21 暴露而非静默吞); 新增 **R21 node-id-coverage**(应渲染节点漏挂 data-node-id 即 exit 1,机械强制 §5.1.1 铁律,堵 R18/R19/R20 遇空 classMap 静默 continue 的逃逸); §6.0.2 禁生成流程用 `--force-skip`

@@ -164,0 +176,0 @@ - **v1.2.0** 校验范式从"黑名单抽查"→"以 cache 为真值逐节点对账": loadCache 标注 `_inBakedSubtree`/`_hidden`/`_templateDup` + cssMatch 共享 SCSS 嵌套匹配(R02/R06 假阳性根源清除); 新增 R17 no-baked-dom / R18 flex-direction / R19 padding / R20 absolute-position 四条对账规则

@@ -610,2 +610,9 @@ #!/usr/bin/env node

if (args.outManifest) {
// v1.2.5 确认留痕: 默认 confirmed:false,用户在步骤 2.6 确认后由 figma.mjs confirm-slices 翻 true;
// pp-d2c.config.json 配 slice.confirmBeforeContinue === false(全自动流水)时直接落 true。
let autoConfirm = false
try {
const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'pp-d2c.config.json'), 'utf8'))
autoConfirm = cfg && cfg.slice && cfg.slice.confirmBeforeContinue === false
} catch { /* 无 config 按需确认 */ }
const manifest = {

@@ -621,2 +628,3 @@ generatedAt: stamp,

miss: r.miss,
confirmed: autoConfirm,
entries: r.manifestEntries || [],

@@ -623,0 +631,0 @@ })),

#!/usr/bin/env node
// check-rules.mjs — pp-d2c 硬防线脚本 (v1.2.1)
// 覆盖 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21
// check-rules.mjs — pp-d2c 硬防线脚本 (v1.2.5)
// 覆盖 R01/R02/R03/R04/R05/R06/R08/R09/R12/R14/R16/R17/R18/R19/R20/R21/R23 + R22(warning)
// v1.2.5:(1) GATE-cache-truncation——合并 cache 中空 GROUP/BOOLEAN_OPERATION = depth 截断实锤,
// 截断 cache 会让逐节点对账真空通过(test29: 25 节点 cache 全防线失效);(2) R21 反向对账——
// 产物 data-node-id 必须存在于 cache(幻觉 id);(3) 新增 R23 size-fidelity——显式 px 宽高须
// ≈ bbox×scale,1×1+overflow:hidden 锚点欺诈点名(test28);(4) GATE-rule-hits 收紧——fallback
// 占位须伴随 assets.txt [Rule-Scan 降级] 记录;(5) GATE-slice-confirm——manifest confirmed
// 须为 true(figma.mjs confirm-slices 留痕,legacy 缺字段仅 warning)。
// v1.2.4:(1) --block 局部化——--root <nodeId> 或从产物 data-node-id 推断(LCA),cache 裁剪到
// block 子树,消除 R21/R03 对 block 外节点的全量误报;(2) GATE-rule-hits 门禁——rule-hits.json
// 缺失即 exit 1(含 assets.txt 消费证明捏造检测);(3) IMG-reconcile 三方对账(--merge)——产物
// 图片引用必须来自 slice-manifest;(4) R20 增强 position:absolute 声明强制;(5) 新增 R22
// empty-visual-btn(warning 级);规则可返回 severity:'warning' 进 warnings 不阻断。
// v1.2.3 软→硬迁移:原 Rule-Scan 软防线中机械可判的 5 条下沉硬防线,逐节点对账不依赖 sub- 触发——
// R03 implicit-image(≥3 真矢量路径无切图) / R04 text-gradient(末位 GRADIENT/IMAGE 须 background-clip:text) /
// R09 btn-bgc(bgc 渐变须落 gradient) / R12 flat-mode-naming(flat 同名类冲突) / R14 fixed-z-index(多 fixed z 层级)。
// R07/R10/R11/R13/R15 需 LLM 语义判定,仍留软防线。所有新规则一律保守(宁漏报不误判,边界 skip)。
// v1.2.0 对账升级:loadCache 标注 _inBakedSubtree / _hidden / _templateDup;

@@ -21,3 +36,4 @@ // R02/R06 跳过 baked·隐藏·模板副本 + SCSS &__ 嵌套匹配(lib/cssMatch.mjs)消除假阳性;

import path from 'node:path';
import { findProjectRoot, loadConfig, loadCache } from './lib/loadCache.mjs';
import fs from 'node:fs';
import { findProjectRoot, loadConfig, loadCache, inferBlockRoot, pruneToSubtree, findCacheTruncation } from './lib/loadCache.mjs';
import { loadProduct } from './lib/loadProduct.mjs';

@@ -29,5 +45,10 @@ import { buildNodeIdToClassName } from './lib/nodeIdToClassName.mjs';

import * as R02 from './rules/R02-fills-image.mjs';
import * as R03 from './rules/R03-implicit-image.mjs';
import * as R04 from './rules/R04-text-gradient.mjs';
import * as R05 from './rules/R05-space-between.mjs';
import * as R06 from './rules/R06-text-solid-last.mjs';
import * as R08 from './rules/R08-bg-landing-form.mjs';
import * as R09 from './rules/R09-btn-bgc.mjs';
import * as R12 from './rules/R12-flat-mode-naming.mjs';
import * as R14 from './rules/R14-fixed-z-index.mjs';
import * as R16 from './rules/R16-no-flatten-text.mjs';

@@ -39,7 +60,166 @@ import * as R17 from './rules/R17-no-baked-dom.mjs';

import * as R21 from './rules/R21-node-id-coverage.mjs';
import * as R22 from './rules/R22-empty-visual-btn.mjs';
import * as R23 from './rules/R23-size-fidelity.mjs';
const ALL_RULES = [R01, R02, R05, R06, R08, R16, R17, R18, R19, R20, R21];
const ALL_RULES = [R01, R02, R03, R04, R05, R06, R08, R09, R12, R14, R16, R17, R18, R19, R20, R21, R22, R23];
// ── rule-hits 存在性门禁(v1.2.4,问题5) ─────────────────────────
// Rule-Scan 是步骤 3.5 硬性动作;v1.2.2 起无 sub- 页面也必须对页面根跑一次(虚拟 block)。
// test24-27 实测: agent 跳过 Rule-Scan 并在 assets.txt 捏造"§3.5 允许合并到 UI 侧"许可
// → 文本约束拦不住,此处机械兜底。缺失 = violation(exit 1);降级占位(v0.3.21-fallback)算存在。
function checkRuleHitsGate(mode, productDir) {
const violations = [];
const need = [];
if (mode === 'block') {
need.push({ dir: productDir, label: `block ${path.basename(productDir)}` });
} else {
const blocksDir = path.join(productDir, 'blocks');
let blockDirs = [];
if (fs.existsSync(blocksDir)) {
blockDirs = fs.readdirSync(blocksDir)
.map((d) => path.join(blocksDir, d))
.filter((p) => {
try {
return fs.statSync(p).isDirectory() && fs.readdirSync(p).some((f) => /\.(jsx|tsx)$/.test(f));
} catch { return false; }
});
}
if (blockDirs.length > 0) for (const d of blockDirs) need.push({ dir: d, label: `block ${path.basename(d)}` });
else need.push({ dir: productDir, label: '页面根(无 sub-,v1.2.2 虚拟 block)' });
}
for (const { dir, label } of need) {
const f = path.join(dir, 'rule-hits.json');
if (fs.existsSync(f)) {
let parsed = null;
try { parsed = JSON.parse(fs.readFileSync(f, 'utf8')); } catch {
violations.push(gateViolation(label, f, 'rule-hits.json 存在但不是合法 JSON'));
continue;
}
// v1.2.5 收紧: fallback 占位仅限「Rule-Scan 真实二次失败」——必须伴随 assets.txt 的
// [Rule-Scan 降级] 记录(含失败原因)。无记录 = 用占位绕门禁(典型 test29:
// rule-hits 写 fallback 占位,assets.txt 却写"Rule-Scan 降级: 无",自相矛盾)。
const gb = String((parsed && parsed.generated_by) || '');
if (/fallback/i.test(gb)) {
let degraded = false;
const at2 = path.join(dir, 'assets.txt');
try {
degraded = fs.existsSync(at2) && fs.readFileSync(at2, 'utf8').includes('[Rule-Scan 降级]');
} catch { /* 读不到按无记录处理 */ }
if (!degraded) {
violations.push(gateViolation(label, f, `rule-hits 为 fallback 占位(${gb}),但 assets.txt 无 [Rule-Scan 降级] 失败记录——占位仅限真实二次派发失败,疑似用占位绕过 Rule-Scan`));
}
}
continue;
}
// 防捏造: 文件缺失但 assets.txt 已写消费证明
let fabricated = '';
const at = path.join(dir, 'assets.txt');
try {
if (fs.existsSync(at) && fs.readFileSync(at, 'utf8').includes('rule-hits 消费证明')) {
fabricated = ';且 assets.txt 已写"rule-hits 消费证明"(疑似捏造,文件并不存在)';
}
} catch { /* assets 不可读不影响门禁本身 */ }
violations.push(gateViolation(label, f, `缺失 ${f}${fabricated}`));
}
return violations;
}
function gateViolation(label, file, actual) {
return {
rule: 'GATE-rule-hits',
nodeId: '-',
name: label,
type: 'GATE',
expected: `${label} 必须存在 rule-hits.json(步骤 3.5 Rule-Scan 落盘;二次降级也须写 v0.3.21-fallback 占位)`,
actual,
file,
line: 0,
snippet: '',
};
}
// ── 切图三方对账(v1.2.4,问题3;--merge 时执行) ───────────────────
// 产物图片引用必须来自 slice-manifest(步骤 2.6 只消费清单契约):
// 产物引用 ∉ manifest → violation(疑似绕清单手工切图);
// manifest 条目未被引用 → warning(可能隐藏层/被裁,不阻断)。
// manifest 缺失 → warning 跳过(旧项目/无图页面不硬卡)。assets.txt 侧对账留给主 agent §6 文本层。
function checkImageReconciliation(projectRoot, cacheKey, product) {
const violations = [];
const warnings = [];
const cacheDir = path.join(projectRoot, '.d2c-cache', cacheKey);
let manifestFiles = [];
try {
manifestFiles = fs.readdirSync(cacheDir).filter((f) => /^slice-manifest-.*\.json$/.test(f));
} catch { /* cache 目录不可读走缺失分支 */ }
if (manifestFiles.length === 0) {
warnings.push({ rule: 'IMG-reconcile', reason: '未找到 slice-manifest-*.json,跳过三方对账' });
return { violations, warnings };
}
const entries = new Set();
for (const mf of manifestFiles) {
try {
const m = JSON.parse(fs.readFileSync(path.join(cacheDir, mf), 'utf8'));
for (const t of m.themes || []) {
for (const e of t.entries || []) entries.add(e.filename);
// 切图确认留痕(v1.2.5,GATE-slice-confirm): reskin-slice 落盘 confirmed:false,
// 用户确认后由 figma.mjs confirm-slices 翻 true;false = 未经确认就走到了合并阶段。
// 字段缺失(v1.2.5 前的 legacy manifest) → warning 不阻断。
if (t.confirmed === false) {
violations.push({
rule: 'GATE-slice-confirm',
nodeId: '-',
name: `${mf}#${t.slug || ''}`,
type: 'GATE',
expected: '步骤 2.6 切图确认暂停后,须经用户确认并执行 figma.mjs confirm-slices 将 manifest confirmed 置 true,再进入生成',
actual: 'manifest confirmed=false——切图结果未经用户确认(口头"别问了"不豁免;跳过确认的唯一通道是 config slice.confirmBeforeContinue=false,该配置下 reskin-slice 直接落 confirmed=true)',
file: path.join(cacheDir, mf),
line: 0,
snippet: '',
});
} else if (t.confirmed === undefined) {
warnings.push({ rule: 'GATE-slice-confirm', reason: `${mf}#${t.slug || ''} 无 confirmed 字段(legacy manifest,建议重跑 reskin-slice)` });
}
}
} catch { warnings.push({ rule: 'IMG-reconcile', reason: `${mf} 解析失败,已跳过` }); }
}
const refRe = /([\w./-]+\.(?:png|jpe?g|webp|svg|gif))/gi;
const refs = new Set();
for (const f of [...product.jsx, ...product.style]) {
for (const m of f.content.matchAll(refRe)) refs.add(path.posix.basename(m[1]));
}
// 保守匹配: JSX 动态拼接(如 \`\${x}__bg.png\`)会让正则只捕到文件名尾部碎片;
// manifest 任一条目以该碎片结尾即视为已消费,宁漏报不误判。
const matchedEntries = new Set();
const refConsumed = (r) => {
if (entries.has(r)) { matchedEntries.add(r); return true; }
let hit = false;
for (const e of entries) {
if (e.endsWith(r)) { matchedEntries.add(e); hit = true; }
}
return hit;
};
for (const r of refs) {
if (!refConsumed(r)) {
violations.push({
rule: 'IMG-reconcile',
nodeId: '-',
name: r,
type: 'IMG',
expected: '产物引用的切图必须来自 slice-manifest(步骤 2.6 只消费清单契约)',
actual: `产物引用 ${r} 不在任何 slice-manifest 中(疑似绕清单手工切图)`,
file: '(product)',
line: 0,
snippet: '',
});
}
}
const unused = [...entries].filter((k) => !matchedEntries.has(k));
if (unused.length) {
warnings.push({ rule: 'IMG-reconcile', reason: `manifest 中 ${unused.length} 张切图未被产物引用: ${unused.slice(0, 10).join(', ')}${unused.length > 10 ? ' …' : ''}` });
}
return { violations, warnings };
}
function parseArgv(argv) {
const args = { mode: null, dir: null, cacheKey: null, forceSkip: [] };
const args = { mode: null, dir: null, cacheKey: null, root: null, forceSkip: [] };
for (let i = 2; i < argv.length; i++) {

@@ -50,2 +230,3 @@ const a = argv[i];

else if (a === '--cache-key') { args.cacheKey = argv[++i]; }
else if (a === '--root') { args.root = argv[++i]; }
else if (a === '--force-skip') {

@@ -62,10 +243,14 @@ args.forceSkip = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);

function printHelp() {
process.stdout.write(`check-rules.mjs (pp-d2c v1.2.1)
process.stdout.write(`check-rules.mjs (pp-d2c v1.2.5)
Usage:
node check-rules.mjs --block <blockDir> --cache-key <fileKey>
node check-rules.mjs --block <blockDir> --cache-key <fileKey> [--root <nodeId>]
node check-rules.mjs --merge <pageDir> --cache-key <fileKey>
node check-rules.mjs --block <blockDir> --cache-key <fileKey> --force-skip R05,R06
Rules covered: R01 R02 R05 R06 R08 R16 R17 R18 R19 R20 R21
--root: block 子树根 nodeId(局部化对账范围);缺省时 --block 模式自动从产物 data-node-id 推断(LCA)
Rules covered: R01 R02 R03 R04 R05 R06 R08 R09 R12 R14 R16 R17 R18 R19 R20 R21 R23 R22(warn)
Gates: GATE-cache-truncation(cache 完整性) GATE-rule-hits(存在性+fallback 收紧)
IMG-reconcile(--merge 三方对账) GATE-slice-confirm(--merge 切图确认留痕)
Exit: 0=ok, 1=violations, 2=env-error

@@ -107,2 +292,51 @@ `);

// --block 局部化(v1.2.4): cache 装载的是 fileKey 全量,block 产物只覆盖本子树,
// 必须裁剪到 block 根,否则 R21/R03 等把 block 外节点全部误报。
// 根来源: --root 显式指定 > 从产物 data-node-id 推断(LCA); merge 模式默认全量。
if (args.root && !cache.nodes[args.root]) {
fatal(`--root ${args.root} not found in cache`);
}
let scopeRoot = args.root;
if (!scopeRoot && args.mode === 'block') {
scopeRoot = inferBlockRoot(cache.nodes, classMap);
if (!scopeRoot) {
warnings.push({ rule: 'scope', reason: '--block 无法从产物 data-node-id 推断子树根,退回全量 cache 对账(可能出现 block 外误报,建议显式 --root)' });
}
}
if (scopeRoot) {
const before = Object.keys(cache.nodes).length;
cache.nodes = pruneToSubtree(cache.nodes, scopeRoot);
warnings.push({ rule: 'scope', reason: `对账范围=子树 ${scopeRoot}(${args.root ? '--root' : '产物推断'}), cache ${before}→${Object.keys(cache.nodes).length} 节点` });
}
// cache 完整性门禁(v1.2.5,GATE-cache-truncation): 截断 cache 会让逐节点对账真空通过,
// 必须先于一切规则拦截——空 GROUP/BOOLEAN_OPERATION = fetch depth 截断实锤。
const trunc = findCacheTruncation(cache.nodes);
for (const t of trunc.hard) {
violations.push({
rule: 'GATE-cache-truncation',
nodeId: t.nodeId,
name: t.name,
type: t.type,
expected: 'GROUP/BOOLEAN_OPERATION 在 Figma 中必有子节点;cache 中为空 = fetch-node depth 截断,该子树内容缺失',
actual: '空容器(不带 --depth 重拉该子树后重新生成与对账;凭截断 cache 出码必然丢内容)',
file: '(cache)',
line: 0,
snippet: '',
});
}
for (const t of trunc.soft) {
warnings.push({ rule: 'GATE-cache-truncation', reason: `${t.name}(${t.nodeId}) ${t.type} children 为空,疑似截断` });
}
// rule-hits 存在性门禁(v1.2.4): Rule-Scan 未跑 → 直接违规,不看规则结果
for (const v of checkRuleHitsGate(args.mode, productDir)) violations.push(v);
// 切图三方对账(v1.2.4): 合并阶段核对产物图片引用 ↔ slice-manifest
if (args.mode === 'merge') {
const rec = checkImageReconciliation(projectRoot, args.cacheKey, product);
for (const v of rec.violations) violations.push(v);
for (const w of rec.warnings) warnings.push(w);
}
for (const rule of ALL_RULES) {

@@ -117,3 +351,7 @@ checked.push(rule.id);

const hits = rule.check({ cache, product, config, classMap });
for (const h of hits) violations.push(h);
// severity=warning 的命中(如 R22)进 warnings 不阻断;其余进 violations
for (const h of hits) {
if (h.severity === 'warning') warnings.push({ rule: h.rule, reason: `${h.name}(${h.nodeId}): ${h.actual}`, detail: h });
else violations.push(h);
}
} catch (e) {

@@ -120,0 +358,0 @@ warnings.push({ rule: rule.id, reason: `rule crashed: ${e.message}` });

@@ -223,2 +223,22 @@ #!/usr/bin/env node

// 深度截断嫌疑检测: GROUP/BOOLEAN_OPERATION/INSTANCE/COMPONENT 在 Figma 中必有子节点,
// 位于 depth 边界却 children 为空 → 子树被 REST depth 参数截断,视觉内容不在 cache 里
// (典型 test24 btn-qiang 136:45810: depth=8 边界空 GROUP,按钮真实内容丢失 → 产物透明热区)。
// FRAME 可合法为空(占位盒),不纳入,避免误报。
const NEVER_EMPTY_TYPES = new Set(['GROUP', 'BOOLEAN_OPERATION', 'INSTANCE', 'COMPONENT'])
function findTruncatedSuspects(root, depth) {
if (!depth) return []
const suspects = []
const walk = (n, d) => {
if (!n || typeof n !== 'object') return
const empty = !Array.isArray(n.children) || n.children.length === 0
if (d >= depth && empty && NEVER_EMPTY_TYPES.has(n.type)) {
suspects.push({ id: n.id, name: n.name, type: n.type })
}
for (const c of n.children || []) walk(c, d + 1)
}
walk(root, 0)
return suspects
}
async function cmdFetchNode(positional, flags) {

@@ -240,4 +260,9 @@ const [fileKey, nodeId] = positional

const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
if (!depth || (cached._depth && cached._depth >= depth)) {
return output({ ok: true, data: { cached: true, node: cached.node } })
// 复用条件: 全量请求(无 --depth)只能复用全量 cache(_depth=null);
// 带 depth 请求可复用全量 cache 或深度不小于本次的 cache。
// 旧逻辑 !depth 即复用,会把深度截断的旧 cache 当全量用 → 子树静默丢失。
const cachedIsFull = cached._depth == null
if (cachedIsFull || (depth && cached._depth >= depth)) {
const truncatedSuspects = cachedIsFull ? [] : findTruncatedSuspects(cached.node, cached._depth)
return output({ ok: true, data: { cached: true, _depth: cached._depth, truncatedSuspects, node: cached.node } })
}

@@ -255,3 +280,4 @@ } catch { /* 缓存损坏,重拉 */ }

fs.writeFileSync(cacheFile, JSON.stringify({ _depth: depth || null, node: doc }, null, 2))
output({ ok: true, data: { cached: false, node: doc } })
const truncatedSuspects = depth ? findTruncatedSuspects(doc, depth) : []
output({ ok: true, data: { cached: false, _depth: depth || null, truncatedSuspects, node: doc } })
} catch (e) {

@@ -317,2 +343,26 @@ fail(e.message)

// ─── 命令: confirm-slices <fileKey> <slug> ──────────────────────
// v1.2.5 切图确认留痕: 步骤 2.6 用户确认切图结果后执行,把 slice-manifest 对应 theme 的
// confirmed 置 true(check-rules --merge 的 GATE-slice-confirm 依赖该字段)。
// 仅在用户明确确认后调用;agent 不得未经确认自行执行(留痕即取证,伪造可事后对会话审计)。
function cmdConfirmSlices(positional) {
const [fileKey, slug] = positional
if (!fileKey || !slug) return fail('用法: figma confirm-slices <fileKey> <slug>')
const { projectRoot } = loadConfig()
const manifestFile = path.join(projectRoot, '.d2c-cache', fileKey, `slice-manifest-${slug}.json`)
if (!fs.existsSync(manifestFile)) return fail(`清单不存在: ${manifestFile}`)
let manifest
try { manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')) } catch (e) {
return fail(`清单解析失败: ${e.message}`)
}
const themes = (manifest.themes || []).filter(t => t.slug === slug)
const targets = themes.length ? themes : (manifest.themes || [])
if (targets.length === 0) return fail(`清单中无 theme 可确认: ${manifestFile}`)
const confirmedAt = new Date().toISOString()
for (const t of targets) { t.confirmed = true; t.confirmedAt = confirmedAt }
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2))
output({ ok: true, data: { manifest: manifestFile, confirmed: targets.map(t => t.slug), confirmedAt } })
}
// ─── 命令: screenshot <fileKey> <nodeId> [--tag=leaf|whole|block] ─

@@ -370,2 +420,3 @@

'export-image': () => cmdExportImage(positional, flags),
'confirm-slices': () => cmdConfirmSlices(positional),
'screenshot': () => cmdScreenshot(positional, flags),

@@ -384,2 +435,3 @@ 'cleanup-tmp': () => cmdCleanupTmp(),

node figma.mjs export-image <fileKey> <nodeId> --filename=<name> [--format=png|svg] [--scale=1|2] [--preserve-effect]
node figma.mjs confirm-slices <fileKey> <slug> (步骤 2.6 用户确认切图后执行,manifest confirmed → true)
node figma.mjs screenshot <fileKey> <nodeId> [--tag=leaf|whole|block] [--scale=2]

@@ -386,0 +438,0 @@ node figma.mjs cleanup-tmp

@@ -83,2 +83,62 @@ import fs from 'node:fs';

// ── --block 局部化(v1.2.4)────────────────────────────────────
// loadCache 装载的是 fileKey 全量节点;--block 模式产物只覆盖本 block 子树,
// 不裁剪会让 R21/R03 等把 block 外所有应渲染节点误报(sub-agent 被迫解释"外部违规")。
// 从产物 data-node-id 集合推断 block 子树根:全部产物节点的最深公共祖先(LCA)。
export function inferBlockRoot(cacheNodes, classMap) {
const ids = Object.keys(classMap).filter((id) => cacheNodes[id]);
if (ids.length === 0) return null;
const chain = (id) => {
const arr = [];
let cur = id;
while (cur) { arr.push(cur); cur = cacheNodes[cur] ? cacheNodes[cur]._parentId : null; }
return arr; // 自身在前,根在后
};
let common = chain(ids[0]);
for (let i = 1; i < ids.length; i++) {
const set = new Set(chain(ids[i]));
common = common.filter((x) => set.has(x));
if (common.length === 0) return null;
}
return common[0] || null; // 最深公共祖先
}
// 把 cache.nodes 裁剪到 rootId 子树(含 rootId 自身)。rootId 的父不在集合内:
// R20 对 rootId 自身会因父 bbox 缺失保守跳过,块内子孙照常对账。
export function pruneToSubtree(cacheNodes, rootId) {
if (!rootId || !cacheNodes[rootId]) return cacheNodes;
const keep = {};
for (const [id, n] of Object.entries(cacheNodes)) {
let cur = id;
while (cur) {
if (cur === rootId) { keep[id] = n; break; }
cur = cacheNodes[cur] ? cacheNodes[cur]._parentId : null;
}
}
return keep;
}
// ── cache 完整性检测(v1.2.5)────────────────────────────────────
// GROUP/BOOLEAN_OPERATION 在 Figma 中必有子节点;合并全部分片后仍为空 = REST depth 截断实锤
// (深分片 walk 时会覆盖浅分片的同 id 节点对象,覆盖后仍空说明没有任何分片拉到其内容)。
// 典型 test29: cache 仅 _depth=1/2 分片、25 节点,逐节点对账因"无节点可对"真空通过。
// 跳过: baked(bg-/img-/x- 自身及子树,像素已烤进 PNG,子树内容与对账无关)/hidden/templateDup。
// INSTANCE/COMPONENT 为空极罕见但理论可构造 → 归 soft(调用方作 warning)。
const NEVER_EMPTY_HARD = new Set(['GROUP', 'BOOLEAN_OPERATION']);
const NEVER_EMPTY_SOFT = new Set(['INSTANCE', 'COMPONENT']);
export function findCacheTruncation(cacheNodes) {
const hard = [];
const soft = [];
for (const [nodeId, node] of Object.entries(cacheNodes)) {
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
if (isNonRecursivePrefix(node.name)) continue; // 整体切图/忽略目标,子树内容不参与对账
if (Array.isArray(node.children) && node.children.length > 0) continue;
if (NEVER_EMPTY_HARD.has(node.type)) hard.push({ nodeId, name: node.name || '(no name)', type: node.type });
else if (NEVER_EMPTY_SOFT.has(node.type)) soft.push({ nodeId, name: node.name || '(no name)', type: node.type });
}
return { hard, soft };
}
function walk(node, acc, parentRealId, inBaked, bakedBy, hidden, templateDup) {

@@ -85,0 +145,0 @@ if (!node || typeof node !== 'object') return;

@@ -1,4 +0,6 @@

// R20 absolute-position(v1.2.0 对账新增)
// R20 absolute-position(v1.2.0 对账新增;v1.2.4 增强 position 声明强制)
// 触发: node.layoutPositioning === 'ABSOLUTE'(脱离父 autolayout 顺流,绝对定位)
// 期望: CSS top ≈ (子.bbox.y − 父.bbox.y) × scale;left ≈ (子.bbox.x − 父.bbox.x) × scale(容差 4px)
// 期望: CSS 必须声明 position: absolute(top/left 为 0 可省数值,position 不可省——
// relative/static 仍占父 flex 流位挤压兄弟,典型 test27 211:435 main__screen);
// CSS top ≈ (子.bbox.y − 父.bbox.y) × scale;left ≈ (子.bbox.x − 父.bbox.x) × scale(容差 4px)
// 违反: 坐标靠猜(典型 test13 img-huochepiao:真值 left≈-10/top≈-13 溢出到背景上,产物却写 top:40/left:40)

@@ -44,5 +46,9 @@ // 跳过: baked / hidden / templateDup / 无 className / 父无 bbox

// 期望值≈0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,容忍不报(避免噪声)。
// 期望值≈0 且产物未显式声明 top/left → 原点绝对定位与顺流视觉等价,容忍不报(避免噪声)。
// 期望非 0 却缺失(丢了真实偏移)、或写了值但对不上(如 huochepiao 40 vs -13)→ 报。
const problems = [];
// v1.2.4: position: absolute 声明本身不可省——检查该元素全部 class 的全部规则体
if (!anyBodyHas(product.style, classes, /position\s*:\s*absolute\b/i)) {
problems.push('缺 position: absolute(relative/static 仍参与父流布局,占位挤压兄弟)');
}
if (cssTop == null) {

@@ -73,2 +79,14 @@ if (Math.abs(expTop) > TOL) problems.push(`缺 top(应 ${expTop}px,丢了真实偏移)`);

// 该元素任一 class 的任一规则体命中 re 即真(position 可能声明在另一条同类规则里)
function anyBodyHas(styleFiles, classes, re) {
for (const cls of classes) {
for (const s of styleFiles) {
for (const b of collectRuleBodies(s.content, cls)) {
if (re.test(b.body)) return true;
}
}
}
return false;
}
function firstBody(styleFiles, classes) {

@@ -75,0 +93,0 @@ for (const cls of classes) {

@@ -20,2 +20,6 @@ // R21 node-id-coverage(v1.2.1 对账新增)

// 副本已被 _templateDup 跳过。
//
// v1.2.5 反向对账:产物 JSX 里每个字面量 data-node-id 必须存在于 cache——
// 不存在 = 幻觉 id(凭记忆/臆造挂 id 应付正向检查)。典型 test29:产物 33 个 id
// 有 11 个不在 cache(浅 cache + 低推理执行器编造)。表达式形式(data-node-id={x})不判。

@@ -52,2 +56,24 @@ export const id = 'R21';

// 反向对账(v1.2.5):产物字面量 data-node-id 必须来自 cache
const idRe = /data-node-id=["']([^"']+)["']/g;
const productIds = new Set();
for (const f of product.jsx) {
for (const m of f.content.matchAll(idRe)) productIds.add(m[1]);
}
for (const pid of productIds) {
if (!cache.nodes[pid]) {
violations.push({
rule: id,
nodeId: pid,
name: '(cache 中不存在)',
type: '?',
expected: '产物 data-node-id 必须来自 cache 真实节点(§5.1.1 铁律的反向:id 不能臆造)',
actual: 'cache 中不存在该 nodeId——幻觉 id,或该子树从未 fetch(凭记忆出码)',
file: '(jsx)',
line: 0,
snippet: '',
});
}
}
return violations;

@@ -54,0 +80,0 @@ }

@@ -5,4 +5,4 @@ # R03 - implicit-image

- **硬防线** (check-rules.mjs 自动拦截): ❌ (语义判断,脚本难)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**:

@@ -9,0 +9,0 @@ - 节点有 `img-` / `bg-` / `bgc-` / `x-` / `input-` / `sub-` / `block-` / `btn-` / `fixed-` / `end-` / `scrollx-` / `scrolly-` 前缀 → 不适用

@@ -5,4 +5,4 @@ # R04 - text-gradient

- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**:

@@ -9,0 +9,0 @@ - 末位可见 fill 是 SOLID → 归 R06

@@ -5,4 +5,4 @@ # R09 - btn-bgc-取值

- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**:

@@ -9,0 +9,0 @@ - `bgc-` 层的 fills 只有单层 SOLID → 直接按 CSS `background-color` 取,不算 R09

@@ -5,4 +5,4 @@ # R12 - flat-mode-naming

- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**:

@@ -9,0 +9,0 @@ - `pp-d2c.config.json` 的 `merge.mode !== 'flat'` → 不适用

@@ -5,4 +5,4 @@ # R14 - fixed-z-index

- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **硬防线** (check-rules.mjs 自动拦截): ✅ (v1.2.3 软→硬迁移,逐节点对账;保守判定,宁漏报不误判)
- **软防线** (Rule-Scan sub-agent 识别): 生成前指引(判定已由硬防线兜底)
- **排斥条件**: 页面只有 1 个 `fixed-` 节点 → 无冲突,不判 z-index

@@ -9,0 +9,0 @@

@@ -25,6 +25,7 @@ # R20 - absolute-position(v1.2.0 对账新增)

2. 期望值**非 0**(|exp| > 4)但产物**缺** `top`/`left` → 丢了真实偏移
3. 产物该元素**任一 class 的任一规则体**均未声明 `position: absolute` → 违规(**v1.2.4 新增**;relative/static 仍占父 flex 流位挤压兄弟,典型 test27 `main__screen` 写 `position: relative` 逃逸)
命中 → 违规。
> **容忍**:期望值 ≈ 0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,不报(避免噪声)。支持 `top: 0` 无单位零与 `inset` 简写。
> **容忍**:期望值 ≈ 0 且产物未显式声明 `top`/`left` → 原点绝对定位数值可省,不报(避免噪声);**但 `position: absolute` 声明本身不可省**(第 3 条)。支持 `top: 0` 无单位零与 `inset` 简写。

@@ -31,0 +32,0 @@ ## 期望产物

@@ -26,2 +26,4 @@ # R21 - node-id-coverage(v1.2.1 对账新增)

**反向对账(v1.2.5)**:产物 JSX 中每个**字面量** `data-node-id` 必须存在于 cache——cache 中不存在 = **幻觉 id**(凭记忆/臆造挂 id 应付正向检查),命中 → 违规。表达式形式(`data-node-id={x}`)不判。典型 test29:产物 33 个 id 有 11 个不在 cache(浅 cache + 低推理执行器编造)。
## 期望产物

@@ -28,0 +30,0 @@

@@ -32,4 +32,4 @@ # pp-d2c 规则库

| R02 | fills-image | 硬防线 | `fills[].some(f => f.type === 'IMAGE' && f.visible !== false)` |
| R03 | implicit-image | 软防线 | 无前缀 + 整棵子树 VECTOR/BOOL/几何 + 无 TEXT/INSTANCE + 无 btn-/input-/sub-/block- 子层 |
| R04 | text-gradient | 软防线 | `TEXT` 节点,fills 末位可见 = `GRADIENT_*` 或 `IMAGE` |
| R03 | implicit-image | 硬防线(v1.2.3) | 无前缀 + 整棵子树 VECTOR/BOOL/几何 + 无 TEXT/INSTANCE + 无 btn-/input-/sub-/block- 子层 |
| R04 | text-gradient | 硬防线(v1.2.3) | `TEXT` 节点,fills 末位可见 = `GRADIENT_*` 或 `IMAGE` |
| R05 | space-between | 硬防线 | `primaryAxisAlignItems === 'SPACE_BETWEEN'` |

@@ -39,8 +39,8 @@ | R06 | text-solid-last | 硬防线 | `TEXT` 节点,fills 末位可见 = `SOLID` |

| R08 | bg-landing-form | 硬防线 | `name.startsWith('bg-')` 或 `name === 'bg'`,产物落地形态错 |
| R09 | btn-bgc-取值 | 软防线 | `btn-` 前缀内含 `bgc-` 子层,bgc 的真 fills 是 GRADIENT/IMAGE |
| R09 | btn-bgc-取值 | 硬防线(v1.2.3) | `btn-` 前缀内含 `bgc-` 子层,bgc 的真 fills 是 GRADIENT/IMAGE |
| R10 | no-fake-solid-color | 软防线 | 产物 CSS 出现 `color: #XXX`,但 cache 里对应节点找不到源头 |
| R11 | mask-vector-css-able | 软防线 | 复合 mask / 多层 vector,CSS 表达不了 → 应切图 |
| R12 | flat-mode-naming | 软防线 | `merge.mode === 'flat'` 下类名跨 block 冲突 |
| R12 | flat-mode-naming | 硬防线(v1.2.3) | `merge.mode === 'flat'` 下类名跨 block 冲突 |
| R13 | unit-scale | 软防线 | Figma px → 产物 px 未换算(应 `outputBase / figmaBase`) |
| R14 | fixed-z-index | 软防线 | 多个 `fixed-` 节点,z-index 未递增 |
| R14 | fixed-z-index | 硬防线(v1.2.3) | 多个 `fixed-` 节点,z-index 未递增 |
| R15 | 同构 map 渲染 | 软防线 | 同层 ≥3 同构子节点,展开成重复代码而非 `.map()` |

@@ -51,11 +51,17 @@ | R16 | no-flatten-text | 硬防线 | GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT 且前缀非 `img-`/`bg-`,产物 jsx 出现 `<img data-node-id="该节点">` |

| R19 | padding | 硬防线 | autolayout 容器 padding 与 `Figma paddingT/R/B/L × scale` 不符(凭空加 / 漏写 / 数值错) |
| R20 | absolute-position | 硬防线 | `layoutPositioning === 'ABSOLUTE'`(非 fixed-),top/left ≠ (子bbox−父bbox)×scale |
| R20 | absolute-position | 硬防线 | `layoutPositioning === 'ABSOLUTE'`(非 fixed-),top/left ≠ (子bbox−父bbox)×scale;或产物未声明 `position: absolute`(v1.2.4) |
| R21 | node-id-coverage | 硬防线 | 应渲染节点(TEXT/autolayout 容器/ABSOLUTE/img-·btn-·input-)在产物 JSX 里找不到 data-node-id |
| R22 | empty-visual-btn | warning(v1.2.4) | btn- 子树无文字/背景/图,产物只剩透明热区(常见根因: cache 深度截断 / 该切图没切) |
| R23 | size-fidelity | 硬防线(v1.2.5) | 显式 px 宽高与 bbox×scale 相差 >4px;1×1+overflow:hidden 锚点欺诈点名 |
## 判定归属说明
**硬防线** (`check-rules.mjs` 自动拦截): 用代码 grep + JSON scan 精确判定,exit 1 拦截 → R01 / R02 / R05 / R06 / R08 / R16 / R17 / R18 / R19 / R20 / R21。
**硬防线 17 条** (`check-rules.mjs` 自动拦截): 用代码 grep + JSON scan 精确判定,exit 1 拦截 → R01 / R02 / R03 / R04 / R05 / R06 / R08 / R09 / R12 / R14 / R16 / R17 / R18 / R19 / R20 / R21(v1.2.5 起含反向对账:产物 data-node-id ∉ cache = 幻觉 id) / R23(v1.2.5)。
**软防线** (Rule-Scan sub-agent 识别): 需 LLM 语义判断,输出 `rule-hits.json` 给 UI sub-agent 参考 → R03 / R04 / R07 / R09 / R10 / R11 / R12 / R13 / R14 / R15。
**软防线** (Rule-Scan sub-agent 识别): 需 LLM 语义判断,输出 `rule-hits.json` 给 UI sub-agent 参考 → R07 / R10 / R11 / R13 / R15。(v1.2.3 起 R03/R04/R09/R12/R14 迁入硬防线)
**warning 级** (`check-rules.mjs` 提示不阻断): R22 empty-visual-btn。另有四道流程门禁: **GATE-cache-truncation**(v1.2.5)——合并 cache 中空 GROUP/BOOLEAN_OPERATION = depth 截断实锤,截断 cache 出码必丢内容;**GATE-rule-hits**(v1.2.4,v1.2.5 收紧)——rule-hits.json 缺失即 exit 1,fallback 占位必须伴随 assets.txt `[Rule-Scan 降级]` 记录;**IMG-reconcile**(v1.2.4,--merge)——产物图片引用必须来自 slice-manifest 三方对账;**GATE-slice-confirm**(v1.2.5,--merge)——manifest `confirmed` 须为 true(`figma.mjs confirm-slices` 用户确认留痕,legacy 缺字段仅 warning)。
**Rule-Scan 扫描范围** (v1.2.4 恢复全量): Rule-Scan 扫**全部规则**出 `rule-hits.json`——软防线 5 条以此为唯一判定点;硬防线命中作为生成前逐节点指引(判决权在 check-rules,指引漏扫不算违规)。
**v1.2.0 对账基座**: R02 / R06 / R17 / R18 / R19 / R20 依赖 `bin/lib/loadCache.mjs` 标注的 `_inBakedSubtree`(整体切图子树)/`_hidden`(隐藏)/`_templateDup`(`.map()` 数据副本),以及 `bin/lib/cssMatch.mjs` 的 SCSS `&__foo` 嵌套匹配。这些标注把"整体切图子树 / 隐藏 / 列表副本 / 嵌套写法"四类假阳性从根源清除,使硬防线报数即真值,校验从"黑名单抽查"升级为"以 cache 为真值逐节点对账"。

@@ -67,2 +73,4 @@

**派发时机**: 每个 `sub-` block 出码前各派一次;**页面无 sub- 时(v1.2.2)对整页派一次**——页面根视为虚拟 block,`rule-hits.json` 落页面根目录(与页面 `assets.txt` 同级)。软防线覆盖不依赖设计师是否标了 sub-。
派发时的完整 prompt:

@@ -74,3 +82,3 @@

任务:
1. Read templates/skills/pp-d2c/rules/*.md (全部 19 条)
1. Read templates/skills/pp-d2c/rules/*.md 全部规则(v1.2.4 恢复全量扫描)
2. Read .d2c-cache/<cache-key>/nodes/ 下与本 block nodeIds 相关的 JSON

@@ -81,4 +89,4 @@ 3. 对本 block 的每个节点, 判断命中了哪些规则

规则命中判定原则:
- 硬防线规则 (R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21): 你也扫,即使 check-rules.mjs 会兜底
- 软防线规则 (R03/R04/R07/R09-R15): 你是唯一识别方
- 硬防线规则 (R01/R02/R03/R04/R05/R06/R08/R09/R12/R14/R16/R17/R18/R19/R20/R21) 与 warning 级 R22: 必须扫出命中作为生成前逐节点指引(判决权在 check-rules.mjs,指引漏扫不算违规,但禁止整类跳过)
- 软防线规则 (R07/R10/R11/R13/R15): 你是唯一识别方
- 排斥条件: 若节点命中高优先级规则, 低优先级规则不再重复列

@@ -126,3 +134,3 @@ - 优先级 (由高到低): R21 > R16 > R17 > R02 > R01 > R05 > R11 > R03 > R04 > R07 > R06 > R09 > R08 > R20 > R18 > R19 > R14 > R15 > R13 > R12 > R10(R21 最高:节点不可追溯则其余绑定类规则无从谈起)

- **硬编码 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20 逻辑**,rules/*.md 是设计文档,不是执行文档
- **硬编码 R01/R02/R03/R04/R05/R06/R08/R09/R12/R14/R16/R17/R18/R19/R20/R21 逻辑**,rules/*.md 是设计文档,不是执行文档
- 假阳性时用 `--force-skip R0X,R0Y` 跳过,但 UI sub-agent 必须在 `assets.txt` 备注 `[脚本误判] R0X {nodeId} 理由: ...`

@@ -162,2 +170,6 @@ - 详细 CLI 见 `templates/skills/pp-d2c/bin/check-rules.mjs --help`

- **v1.2.5** 防线加固批(test28/29 取证):GATE-cache-truncation(cache 完整性);R21 反向对账(幻觉 id);R23 size-fidelity(尺寸忠实度+锚点欺诈);GATE-rule-hits 收紧(fallback 占位须有降级记录);GATE-slice-confirm(切图确认留痕);单 agent 执行模式(无 sub-agent 平台合法路径)
- **v1.2.4** 生成过程缺陷修复批(test24-27 取证):check-rules --block 局部化(--root/LCA 推断);GATE-rule-hits 门禁(缺失即 exit 1,含消费证明捏造检测);IMG-reconcile 三方对账;R20 强制 `position: absolute` 声明;新增 R22 empty-visual-btn(warning 级);Rule-Scan 恢复全量扫描出指引(判决权仍在 check-rules)
- **v1.2.3** 软→硬迁移:R03/R04/R09/R12/R14 从软防线下沉 check-rules 硬防线(机械可判、逐节点对账,不依赖 sub- 触发,exit 1 阻断);软防线剩 R07/R10/R11/R13/R15(需 LLM 语义);新硬规则一律保守(宁漏报不误判,边界 skip)
- **v1.2.2** Rule-Scan 触发与 sub- 解耦:执行清单 sub- block 数为 0 时,主 agent 出码前对整页跑一次 Rule-Scan(页面根为虚拟 block,`rule-hits.json` 落页面根目录);修复无 sub- 页面软规则 R03/R04/R07/R09-R15 完全不触发的覆盖空档
- **v1.2.1** `_inBakedSubtree` 移除 bgc-(bgc- 盒级 CSS 写父、非切图,子孙误放 TEXT 应被 R06/R21 暴露而非静默吞); 新增 **R21 node-id-coverage**(应渲染节点漏挂 data-node-id 即 exit 1,机械强制 §5.1.1 铁律,堵 R18/R19/R20 遇空 classMap 静默 continue 的逃逸); §6.0.2 禁生成流程用 `--force-skip`

@@ -164,0 +176,0 @@ - **v1.2.0** 校验范式从"黑名单抽查"→"以 cache 为真值逐节点对账": loadCache 标注 `_inBakedSubtree`/`_hidden`/`_templateDup` + cssMatch 共享 SCSS 嵌套匹配(R02/R06 假阳性根源清除); 新增 R17 no-baked-dom / R18 flex-direction / R19 padding / R20 absolute-position 四条对账规则

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display