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.4.0
to
1.5.0-beta.0
+344
templates/skills/pp-d2c-rn/bin/check-rules.mjs
#!/usr/bin/env node
// check-rules.mjs — pp-d2c-rn 硬防线脚本 (v1.0.0-P0,聚合器复制自 h5 pp-d2c v1.2.5,四处适配:
// ① import 换 styleMatch / nodeIdToStyleKey;② ALL_RULES 为 RN 规则清单;
// ③ IMG-reconcile 文件名正则通用,RN 引用形态(require/source={{uri}}/ImageBackground/FastImage/${ASSET_PREFIX})天然覆盖;
// ④ CLI 契约、exit code(0/1/2)、GATE 执行顺序不变)
// 覆盖(v1.0.0 全量): 21 条 exit-1(R01-R06/R08/R09/R12/R14/R16-R21/R23 + RN01-RN04)
// + R22(warning 级) + 四道门禁(GATE-cache-truncation / GATE-rule-hits /
// IMG-reconcile / GATE-slice-confirm,后两道仅 --merge)。
// RN 特有规则 RN01-RN04 用独立命名空间,与 h5 未来新增的 R24+ 隔离。
// rn 收紧(对 h5 的排他性差异): config 缺 unit 段时 exit 2——rpx 口径下兜底默认 scale
// 会全量误判,强制 config 显式声明。
//
// 用法:
// 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 R19,R20
//
// exit code:
// 0 — ok=true, 全通过 (可能有 warnings)
// 1 — ok=false, 有 violations
// 2 — 环境错误 (cache/产物/config 缺失)
import path from 'node:path';
import fs from 'node:fs';
import { findProjectRoot, loadConfig, loadCache, inferBlockRoot, pruneToSubtree, findCacheTruncation } from './lib/loadCache.mjs';
import { loadProduct } from './lib/loadProduct.mjs';
import { buildNodeIdToStyleKey } from './lib/nodeIdToStyleKey.mjs';
import { makeReport, printReport } from './lib/report.mjs';
import * as R01 from './rules/R01-fixed-position.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';
import * as R17 from './rules/R17-no-baked-dom.mjs';
import * as R18 from './rules/R18-flex-direction.mjs';
import * as R19 from './rules/R19-padding.mjs';
import * as R20 from './rules/R20-absolute-position.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';
import * as RN01 from './rules/RN01-scroll-skeleton.mjs';
import * as RN02 from './rules/RN02-flow-child-position.mjs';
import * as RN03 from './rules/RN03-no-percent-fill.mjs';
import * as RN04 from './rules/RN04-styles-file-separation.mjs';
const ALL_RULES = [
R01, R02, R03, R04, R05, R06, R08, R09, R12, R14,
R16, R17, R18, R19, R20, R21, R22, R23,
RN01, RN02, RN03, RN04,
];
// ── rule-hits 存在性门禁(复制自 h5 v1.2.4/v1.2.5,零样式耦合;P1 打开调用) ─────
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-,虚拟 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;
}
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;
}
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 落盘;二次降级也须写 fallback 占位)`,
actual,
file,
line: 0,
snippet: '',
};
}
// ── 切图三方对账(复制自 h5;文件名正则与引用语法无关,RN 五种引用形态天然覆盖;P1 打开调用) ──
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);
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]));
}
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, root: null, forceSkip: [] };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--block') { args.mode = 'block'; args.dir = argv[++i]; }
else if (a === '--merge') { args.mode = 'merge'; args.dir = argv[++i]; }
else if (a === '--cache-key') { args.cacheKey = argv[++i]; }
else if (a === '--root') { args.root = argv[++i]; }
else if (a === '--force-skip') {
args.forceSkip = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
} else if (a === '-h' || a === '--help') {
printHelp();
process.exit(0);
}
}
return args;
}
function printHelp() {
process.stdout.write(`check-rules.mjs (pp-d2c-rn v1.0.0)
Usage:
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 R19,R20
--root: block 子树根 nodeId(局部化对账范围);缺省时 --block 模式自动从产物 data-node-id 推断(LCA)
Rules (21 exit-1): R01-R06 R08 R09 R12 R14 R16-R21 R23 + RN01-RN04
Warning 级: R22 empty-visual-btn
Gates: GATE-cache-truncation / GATE-rule-hits(全模式);IMG-reconcile / GATE-slice-confirm(--merge)
Exit: 0=ok, 1=violations, 2=env-error
`);
}
function fatal(msg) {
process.stderr.write(`[check-rules] ERROR: ${msg}\n`);
process.exit(2);
}
function main() {
const args = parseArgv(process.argv);
if (!args.mode || !args.dir) fatal('missing --block <dir> or --merge <dir>');
if (!args.cacheKey) fatal('missing --cache-key <fileKey>');
const productDir = path.resolve(args.dir);
const product = loadProduct(productDir);
if (product.error) fatal(product.error);
if (product.jsx.length === 0 && product.style.length === 0) {
fatal(`no jsx/style found under ${productDir}`);
}
const projectRoot = findProjectRoot(productDir);
if (!projectRoot) fatal('pp-d2c.config.json not found in ancestors of ' + productDir);
const config = loadConfig(projectRoot);
if (!config) fatal('failed to load pp-d2c.config.json at ' + projectRoot);
// rn 收紧: unit 段必须显式声明——rpx 口径下兜底默认 scale 会让 R19/R20/R23 全量误判
if (!config.unit || typeof config.unit.scale !== 'number') {
fatal('config.unit.scale missing — rn 侧禁止兜底默认,请在 pp-d2c.config.json 显式声明 unit 段');
}
const cache = loadCache(projectRoot, args.cacheKey);
if (cache.error) fatal(cache.error);
const classMap = buildNodeIdToStyleKey(product.jsx);
const checked = [];
const skipped = [];
const violations = [];
const warnings = [];
// --block 局部化: cache 装载的是 fileKey 全量,block 产物只覆盖本子树,
// 必须裁剪到 block 根,否则 R21 等把 block 外节点全部误报。
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 完整性门禁(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 为空,疑似截断` });
}
// GATE-rule-hits: rule-hits.json 缺失/捏造/占位无降级记录 → exit 1(执行顺序对齐 h5:
// cache-truncation → rule-hits → 规则循环 → merge 时 IMG-reconcile)
for (const v of checkRuleHitsGate(args.mode, productDir)) violations.push(v);
for (const rule of ALL_RULES) {
checked.push(rule.id);
if (args.forceSkip.includes(rule.id)) {
skipped.push(rule.id);
warnings.push({ rule: rule.id, reason: 'skipped via --force-skip' });
continue;
}
try {
const hits = rule.check({ cache, product, config, classMap, mode: args.mode });
// severity=warning 的命中进 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) {
warnings.push({ rule: rule.id, reason: `rule crashed: ${e.message}` });
}
}
// 切图三方对账 + 确认留痕(仅 --merge:产物引用 ⊆ slice-manifest;manifest confirmed 必须为 true)
if (args.mode === 'merge') {
const rec = checkImageReconciliation(projectRoot, args.cacheKey, product);
violations.push(...rec.violations);
warnings.push(...rec.warnings);
}
const report = makeReport({ checked, skipped, violations, warnings });
printReport(report);
process.exit(report.ok ? 0 : 1);
}
main();
// 同步副本:主本 templates/skills/pp-d2c/bin/lib/loadCache.mjs,上游修复须同步搬运
import fs from 'node:fs';
import path from 'node:path';
export function findProjectRoot(startDir) {
let dir = path.resolve(startDir);
while (true) {
if (fs.existsSync(path.join(dir, 'pp-d2c.config.json'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
export function loadConfig(projectRoot) {
const p = path.join(projectRoot, 'pp-d2c.config.json');
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
// "子孙不生成独立 DOM" 的前缀:命中即整体导出 / 忽略,不再向内递归,其子孙不应作为
// 独立 DOM 出现,也不应被 R02/R06 逐个溯源。**仅两类**:
// - bg- / img-(含裸词 bg/img):整体切图,子孙像素**已烤进父层 PNG**(baked,可见)
// - x-:整体忽略,子孙**被丢弃**(ignored,不可见)
// **不含 bgc-**:bgc- 是"把盒级 CSS(fills/描边/圆角/阴影) 写到父元素",子孙**没有烤进任何位图**。
// 若把 bgc- 也算作 baked,会让误放在 bgc- 下的 TEXT 被静默跳过 R06 + R17 禁 DOM → 静默丢内容。
// 故 bgc- 子孙走正常规则:R06 会报"无 className"、R21 会报"不可追溯",把结构错误暴露出来而非吞掉。
const NO_RENDER_PREFIXES = ['bg-', 'img-', 'x-'];
const NO_RENDER_BARE = ['bg', 'img', 'x'];
export function isNonRecursivePrefix(name) {
if (!name || typeof name !== 'string') return false;
const n = name.trim();
if (NO_RENDER_PREFIXES.some((p) => n.startsWith(p))) return true;
if (NO_RENDER_BARE.includes(n)) return true; // 裸词 bg / img / x(与 R16 白名单口径一致)
return false;
}
// 结构签名:捕捉"同构"——同 type + 同层级子结构(深度 3,不含具体文案)。
// 用于识别 `.map()` 列表项:≥2 个同签名的容器兄弟 = 列表,非首项是数据副本。
function structureSig(node, depth) {
if (depth <= 0 || !Array.isArray(node.children) || node.children.length === 0) {
return node.type || '?';
}
return (node.type || '?') + '(' + node.children.map((c) => structureSig(c, depth - 1)).join(',') + ')';
}
// 标记某节点 children 中的"模板重复项":同构容器兄弟里的非首个 → __isDup=true。
// 仅对"有自身子结构的容器"生效(叶子如并列 TEXT "20"/"元" 不算列表项,不误标)。
function markTemplateDups(node) {
if (!Array.isArray(node.children) || node.children.length < 2) return;
const seen = new Map(); // sig -> 已出现
for (const c of node.children) {
if (!c || typeof c !== 'object' || !c.id) continue;
if (!Array.isArray(c.children) || c.children.length === 0) continue; // 叶子不参与列表判定
const sig = structureSig(c, 3);
if (seen.has(sig)) c.__isDup = true; // 非首个同构兄弟 = 数据副本
else seen.set(sig, true);
}
}
export function loadCache(projectRoot, cacheKey) {
const nodesDir = path.join(projectRoot, '.d2c-cache', cacheKey, 'nodes');
if (!fs.existsSync(nodesDir)) {
return { error: `cache dir not found: ${nodesDir}`, nodes: {} };
}
const nodes = {};
const files = fs.readdirSync(nodesDir).filter((f) => f.endsWith('.json'));
for (const f of files) {
const raw = fs.readFileSync(path.join(nodesDir, f), 'utf8');
let json;
try {
json = JSON.parse(raw);
} catch (e) {
continue;
}
// 自上而下遍历:跟踪 parent / "是否处于整体切图子树" / "是否隐藏" 状态,直接标注节点对象
// (nodes[id] 存的是同一对象引用,标注即对全局生效)。
walk(json, nodes, null, false, null, false, false);
}
return { nodes };
}
// ── --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) {
if (!node || typeof node !== 'object') return;
let childParentId = parentRealId;
let childInBaked = inBaked;
let childBakedBy = bakedBy;
let childHidden = hidden;
let childTemplateDup = templateDup;
if (node.id && node.type) {
// 节点自身:继承祖先传下来的 baked 状态(前缀节点自身不算 baked,它是切图/忽略目标)
node._parentId = parentRealId;
node._inBakedSubtree = inBaked;
node._bakedBy = inBaked ? bakedBy : null;
// 隐藏传播:自身 visible===false 或任一祖先隐藏 → 该节点不渲染,对账应整体跳过
node._hidden = hidden || node.visible === false;
// 模板重复项:自身被父标为 __isDup(非首个同构兄弟),或祖先已是副本 → 整棵子树是数据副本
node._templateDup = templateDup || node.__isDup === true;
acc[node.id] = node;
childParentId = node.id;
childHidden = node._hidden;
childTemplateDup = node._templateDup;
// 自身若是非递归前缀,则其"子孙"进入 baked 子树(自身不进)
if (!inBaked && isNonRecursivePrefix(node.name)) {
childInBaked = true;
childBakedBy = node.id;
}
}
// 进入 children 前,标记本层的模板重复项(同构容器兄弟的非首个)
markTemplateDups(node);
if (Array.isArray(node.children)) {
for (const c of node.children) walk(c, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup);
}
if (node.document) walk(node.document, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup);
if (node.node) walk(node.node, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup);
if (node.nodes && typeof node.nodes === 'object' && !Array.isArray(node.nodes)) {
for (const k of Object.keys(node.nodes)) walk(node.nodes[k], acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup);
}
}
// 产物装载(改写自 h5 的 lib/loadProduct.mjs)——rn 侧样式文件识别从后缀名改为双条件:
// 文件名匹配 styles.ts|styles.js|*.styles.ts|*.styles.js 且内容含 StyleSheet.create
// (避免把业务 .ts 误收为样式文件)。返回结构 { jsx, style } 字段名与 h5 一致,规则层无感。
import fs from 'node:fs';
import path from 'node:path';
const JSX_EXTS = ['.jsx', '.tsx'];
const STYLE_NAME_RE = /(?:^|\.)styles\.(?:ts|js)$/;
export function loadProduct(dir) {
const absDir = path.resolve(dir);
if (!fs.existsSync(absDir)) {
return { error: `product dir not found: ${absDir}`, jsx: [], style: [] };
}
const jsx = [];
const style = [];
walk(absDir, absDir, jsx, style);
return { root: absDir, jsx, style };
}
function walk(root, dir, jsx, style) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
walk(root, full, jsx, style);
continue;
}
const ext = getExt(e.name);
const rel = path.relative(root, full);
if (JSX_EXTS.includes(ext)) {
jsx.push({ file: full, rel, content: fs.readFileSync(full, 'utf8') });
} else if (STYLE_NAME_RE.test(e.name)) {
const content = fs.readFileSync(full, 'utf8');
if (content.includes('StyleSheet.create')) {
style.push({ file: full, rel, content });
}
}
}
}
function getExt(name) {
const i = name.lastIndexOf('.');
return i < 0 ? '' : name.slice(i);
}
// 从 jsx 里 grep 出 data-node-id="X:Y" 对应的 style={styles.foo} / style={[styles.a, styles.b]}
// 建立 nodeId -> [styleKey, ...] 的 map(改写自 h5 的 lib/nodeIdToClassName.mjs;
// rn 产物无 className,绑定凭证是 styles 对象的 key)。
// 数组形态里非 styles. 成员(动态变量、inline 对象)忽略——动态部分不参与机械对账。
export function buildNodeIdToStyleKey(jsxFiles) {
const map = new Map(); // nodeId -> Set<styleKey>
for (const { content } of jsxFiles) {
scanFile(content, map);
}
const out = {};
for (const [k, v] of map) out[k] = Array.from(v);
return out;
}
function scanFile(src, map) {
// 简易 JSX 标签匹配(与 h5 同骨架): <Tag ... data-node-id="..." ... /> 允许 attrs 换行
const tagRe = /<[A-Za-z][A-Za-z0-9.-]*\b([^<>]*?)\/?>/gs;
let m;
while ((m = tagRe.exec(src)) !== null) {
const attrs = m[1];
if (!attrs) continue;
const nodeId = pickAttr(attrs, 'data-node-id');
if (!nodeId) continue;
const keys = pickStyleKeys(attrs);
if (!keys || keys.length === 0) continue;
if (!map.has(nodeId)) map.set(nodeId, new Set());
for (const k of keys) map.get(nodeId).add(k);
}
}
function pickAttr(attrs, name) {
// name="value" 或 name={"value"} 或 name={'value'}
const re1 = new RegExp(`\\b${name}="([^"]+)"`);
const m1 = attrs.match(re1);
if (m1) return m1[1];
const re2 = new RegExp(`\\b${name}=\\{['"]([^'"]+)['"]\\}`);
const m2 = attrs.match(re2);
if (m2) return m2[1];
return null;
}
function pickStyleKeys(attrs) {
// style={styles.foo}
const m1 = attrs.match(/\bstyle=\{styles\.([A-Za-z_$][\w$]*)\}/);
if (m1) return [m1[1]];
// style={[styles.a, styles.b, dynamicX]} — 收全部 styles.X 成员,其余忽略
const m2 = attrs.match(/\bstyle=\{\[([^\]]*)\]\}/s);
if (m2) {
const names = [];
const partRe = /styles\.([A-Za-z_$][\w$]*)/g;
let mm;
while ((mm = partRe.exec(m2[1])) !== null) names.push(mm[1]);
return names;
}
return null;
}
// 同步副本:主本 templates/skills/pp-d2c/bin/lib/report.mjs,上游修复须同步搬运
export function makeReport({ checked, skipped, violations, warnings }) {
const failed = Array.from(new Set(violations.map((v) => v.rule)));
const passed = checked.filter((r) => !failed.includes(r) && !skipped.includes(r));
return {
ok: violations.length === 0,
checked,
skipped,
passed,
failed,
violations,
warnings,
};
}
export function printReport(report) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
}
// RN 内核标签集(pp-d2c-rn v1.0.0)——规则脚本共享。
// adapter 启用时,产物标签被 config.adapter.tagMap 映射(如 Image→XImage),
// 规则的标签识别集必须并入映射后的名字,否则 adapter 产物全部漏判。
// tagMap 缺失/未启用时按 RN 原生标签集判,不误报。
const IMAGE_NATIVE = ['Image', 'ImageBackground', 'FastImage'];
const SCROLL_NATIVE = ['ScrollView'];
function mapped(config, kernelTag) {
const a = config && config.adapter;
if (!a || a.enabled !== true || !a.tagMap) return null;
const v = a.tagMap[kernelTag];
return typeof v === 'string' && /^[A-Z][\w.]*$/.test(v) ? v : null;
}
// 图片家族标签(R08/R16/R22 用):Image/ImageBackground/FastImage + tagMap.Image 映射值
export function imageTags(config) {
const out = [...IMAGE_NATIVE];
const m = mapped(config, 'Image');
if (m && !out.includes(m)) out.push(m);
return out;
}
// 滚动容器标签(R01/RN01 用):ScrollView + tagMap.ScrollView 映射值
export function scrollTags(config) {
const out = [...SCROLL_NATIVE];
const m = mapped(config, 'ScrollView');
if (m && !out.includes(m)) out.push(m);
return out;
}
// 在 jsx 文本里搜「图片家族标签 + data-node-id=<nodeId>」,返回 [{ line, snippet }]
export function findImageTagWithNodeId(content, tags, nodeId) {
const esc = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`<(?:${tags.join('|')})\\b[^>]*?data-node-id=["']${esc}["'][^>]*?/?>`, 'gs');
const hits = [];
let m;
while ((m = re.exec(content)) !== null) {
hits.push({
line: content.slice(0, m.index).split('\n').length,
snippet: m[0].length > 200 ? m[0].slice(0, 200) + '...' : m[0],
});
}
return hits;
}
// 计算一段 jsx 文本里滚动容器的开闭区间 [start, end](含嵌套,栈配对)。
// 开闭数不平衡(判不了)返回 null;无滚动容器返回 []。自闭合 <ScrollView ... /> 视为零长区间跳过。
export function scrollViewIntervals(content, tags) {
const tokenRe = new RegExp(`<(/?)(?:${tags.join('|')})\\b`, 'g');
const intervals = [];
const stack = [];
let m;
while ((m = tokenRe.exec(content)) !== null) {
if (m[1] === '/') {
const open = stack.pop();
if (open === undefined) return null; // 闭多于开
intervals.push([open, tokenRe.lastIndex]);
} else {
// 自闭合:标签头在下一个 '>' 前以 '/>' 结束 → 不入栈
const gt = content.indexOf('>', m.index);
if (gt > 0 && content[gt - 1] === '/') continue;
stack.push(m.index);
}
}
if (stack.length) return null; // 开多于闭
return intervals;
}
// styleMatch — RN StyleSheet 样式匹配(pp-d2c-rn v1.0.0,对位 h5 的 lib/cssMatch.mjs)
//
// 背景:rn 产物样式锚定形态(SKILL v0.3.12 强制)——独立 styles.ts 内
// `const styles = StyleSheet.create({ key: {...}, ... })`,JSX 侧 `style={styles.key}` /
// `style={[styles.a, styles.b]}`。h5 的 cssMatch 全部逻辑围绕 CSS/SCSS 文本选择器,
// 对 JS 对象字面量整体失配,故本文件重写而非改写;导出接口与 cssMatch 同形,规则层迁移成本最小。
//
// 解析策略(轻量词法,零依赖,排他性选择:不用 AST):
// - 定位每处 `StyleSheet.create(` 后的对象字面量,花括号平衡切出顶层 `key: {...}` 对;
// - 扫描时跳过字符串('..."..."`...`)与注释(// /* */),避免文案里的花括号干扰计数;
// - 属性值四形态:纯数字 / rpx(数字) / 字符串字面量 / 其他表达式;
// Platform.select/三元/变量引用等动态值 → 该属性标 unparseable,数值类规则保守跳过(宁漏报不误判)。
// ── 词法扫描基础:跳过字符串与注释的逐字符游标 ─────────────────────
function isStrOpen(ch) {
return ch === "'" || ch === '"' || ch === '`';
}
// 从 text[i] 起跳过一个字符串字面量,返回收尾引号后的下标
function skipString(text, i) {
const quote = text[i];
i += 1;
while (i < text.length) {
if (text[i] === '\\') { i += 2; continue; }
if (text[i] === quote) return i + 1;
i += 1;
}
return i;
}
// 从 text[i] 起跳过注释(若 i 处是注释起点),否则原样返回 i
function skipComment(text, i) {
if (text[i] === '/' && text[i + 1] === '/') {
const nl = text.indexOf('\n', i);
return nl < 0 ? text.length : nl + 1;
}
if (text[i] === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2);
return end < 0 ? text.length : end + 2;
}
return i;
}
// 从 text[openIdx]('{' 或 '[' 或 '(')起做括号平衡,返回配对收尾符的下标;失配返回 -1
function matchBalanced(text, openIdx) {
const open = text[openIdx];
const close = open === '{' ? '}' : open === '[' ? ']' : ')';
let depth = 0;
let i = openIdx;
while (i < text.length) {
const j = skipComment(text, i);
if (j !== i) { i = j; continue; }
const ch = text[i];
if (isStrOpen(ch)) { i = skipString(text, i); continue; }
if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) return i;
}
i += 1;
}
return -1;
}
// ── StyleSheet.create 定位与顶层 key 切分 ────────────────────────
// 返回 [{ text, offset }]:每个 StyleSheet.create({...}) 的对象字面量文本(含首尾花括号)与其在全文的起始下标
function createBlocks(styleText) {
const out = [];
const re = /StyleSheet\s*\.\s*create\s*\(/g;
let m;
while ((m = re.exec(styleText)) !== null) {
const braceIdx = styleText.indexOf('{', m.index + m[0].length - 1);
if (braceIdx < 0) continue;
const end = matchBalanced(styleText, braceIdx);
if (end < 0) continue;
out.push({ text: styleText.slice(braceIdx, end + 1), offset: braceIdx });
}
return out;
}
// 切出对象字面量(含首尾花括号)顶层的 key: {...} 条目。
// 返回 [{ key, body, start }]:body = 值对象 `{}` 内文本,start = key 在块内的下标。
// 值不是对象字面量的顶层条目(如展开运算符、简写)跳过——StyleSheet.create 顶层值必为对象。
function topLevelEntries(blockText) {
const out = [];
let i = 1; // 跳过起始 '{'
const end = blockText.length - 1; // 收尾 '}'
while (i < end) {
const j = skipComment(blockText, i);
if (j !== i) { i = j; continue; }
const ch = blockText[i];
if (isStrOpen(ch)) { i = skipString(blockText, i); continue; }
if (/[\s,]/.test(ch)) { i += 1; continue; }
// 尝试匹配 key(裸标识符或引号 key)
const rest = blockText.slice(i);
const km = rest.match(/^([A-Za-z_$][\w$]*|'[^']*'|"[^"]*")\s*:\s*/);
if (!km) {
// 非 key 起点(如展开运算符 ...base):跳到本条目结束(下一个顶层逗号)
i = skipTopLevelValue(blockText, i, end);
continue;
}
const keyRaw = km[1];
const key = keyRaw.replace(/^['"]|['"]$/g, '');
const valIdx = i + km[0].length;
if (blockText[valIdx] === '{') {
const close = matchBalanced(blockText, valIdx);
if (close < 0) break;
out.push({ key, body: blockText.slice(valIdx + 1, close), start: i });
i = close + 1;
} else {
i = skipTopLevelValue(blockText, valIdx, end);
}
}
return out;
}
// 从 from 起跳过一个顶层值(直到深度 0 的逗号或对象收尾),返回新下标
function skipTopLevelValue(blockText, from, end) {
let i = from;
let depth = 0;
while (i < end) {
const j = skipComment(blockText, i);
if (j !== i) { i = j; continue; }
const ch = blockText[i];
if (isStrOpen(ch)) { i = skipString(blockText, i); continue; }
if (ch === '{' || ch === '[' || ch === '(') depth += 1;
else if (ch === '}' || ch === ']' || ch === ')') depth -= 1;
else if (ch === ',' && depth === 0) return i + 1;
i += 1;
}
return i;
}
// ── 导出接口(与 cssMatch 同形) ──────────────────────────────────
// 收集某 styleKey 在一段 styles 文本里的所有规则体。
// 返回 [{ body, line }](body = 该 key 值对象 `{}` 内文本,line = key 所在行)。
export function collectRuleBodies(styleText, styleKey) {
const key = (styleKey || '').trim();
if (!key) return [];
const out = [];
for (const block of createBlocks(styleText)) {
for (const entry of topLevelEntries(block.text)) {
if (entry.key !== key) continue;
const abs = block.offset + entry.start;
out.push({ body: entry.body, line: styleText.slice(0, abs).split('\n').length });
}
}
return out;
}
// 在多个 style 文件里,某 nodeId 的任一 styleKey 是否有规则体命中 propRe。
// keys = classMap[nodeId](可能多个);styleFiles = product.style([{ content, rel }])。
// 命中返回 { hit:true, rel, line, body };否则 { hit:false, firstRel, firstLine, firstSnippet }。
export function findProperty(styleFiles, keys, propRe) {
let firstRel = null, firstLine = 0, firstSnippet = '';
for (const key of keys || []) {
for (const s of styleFiles) {
const bodies = collectRuleBodies(s.content, key);
for (const r of bodies) {
if (propRe.test(r.body)) {
return { hit: true, rel: s.rel, line: r.line, body: r.body };
}
if (!firstRel) {
firstRel = s.rel;
firstLine = r.line;
firstSnippet = r.body.slice(0, 200);
}
}
}
}
return { hit: false, firstRel, firstLine, firstSnippet };
}
// 取某 nodeId 第一个 styleKey 的规则体(用于需要读取声明值的规则,如 R19 padding)。
// 返回 { body, rel, line } 或 null。
export function firstRuleBody(styleFiles, keys) {
for (const key of keys || []) {
for (const s of styleFiles) {
const bodies = collectRuleBodies(s.content, key);
if (bodies.length) return { body: bodies[0].body, rel: s.rel, line: bodies[0].line };
}
}
return null;
}
// 收集某 nodeId 全部 styleKey 的全部规则体文本(R20/R23 跨规则体取值用)。
export function allRuleBodies(styleFiles, keys) {
const out = [];
for (const key of keys || []) {
for (const s of styleFiles) {
for (const r of collectRuleBodies(s.content, key)) out.push(r.body);
}
}
return out;
}
// 列出一段 styles 文本里全部 StyleSheet.create 顶层 key 及其行号(R12 跨文件重复定义检测用)。
// 返回 [{ key, line }],同名 key 多次定义会出现多条。
export function listStyleKeys(styleText) {
const out = [];
for (const block of createBlocks(styleText)) {
for (const entry of topLevelEntries(block.text)) {
const abs = block.offset + entry.start;
out.push({ key: entry.key, line: styleText.slice(0, abs).split('\n').length });
}
}
return out;
}
// ── 数值取用(统一 rpx 剥壳,供 R19/R20/R23 复用) ─────────────────
// 从规则体取某属性的数值声明(取最后一次,对齐"后写覆盖"直觉):
// propName: 16 → { value: 16, viaRpx: false }
// propName: rpx(16) → { value: 16, viaRpx: true }(helperName 可配,默认 rpx)
// propName: '100%' / Platform.select / 三元 / 变量 → { unparseable: true }
// 未声明 → null
// 对账口径:期望 = Figma 原值 × config.unit.scale,rpx(x) 剥壳后的 x 与期望同域直接比
// (rn 模板 config unit.scale=1、responsive.enabled=true,rpx 参数即 Figma 原值)。
export function getNumeric(body, propName, helperName = 'rpx') {
const re = new RegExp(`(?:^|[,{\\s])${propName}\\s*:\\s*([^,\\n}]+)`, 'g');
let raw = null;
let m;
while ((m = re.exec(body)) !== null) raw = m[1].trim();
if (raw == null) return null;
if (/^-?\d+(?:\.\d+)?$/.test(raw)) return { value: parseFloat(raw), viaRpx: false };
const rpxRe = new RegExp(`^${helperName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\(\\s*(-?\\d+(?:\\.\\d+)?)\\s*\\)$`);
const rm = raw.match(rpxRe);
if (rm) return { value: parseFloat(rm[1]), viaRpx: true };
return { unparseable: true };
}
// 跨多个规则体取某属性数值(后出现的规则体覆盖先出现):
// 任一规则体给出可解析值 → 取最后一个可解析值;仅出现 unparseable → { unparseable:true };都没写 → null。
export function getNumericAcross(bodies, propName, helperName = 'rpx') {
let last = null;
let sawUnparseable = false;
for (const b of bodies) {
const r = getNumeric(b, propName, helperName);
if (r == null) continue;
if (r.unparseable) { sawUnparseable = true; continue; }
last = r;
}
if (last) return last;
if (sawUnparseable) return { unparseable: true };
return null;
}
// R01 fixed-position(rn 版,语义变更——RN 没有 CSS position:fixed)
// 触发: node.name.startsWith('fixed-')
// 期望(fixed-* 铁律,SKILL §4.1.1):
// ① style 含 position: 'absolute'
// ② style 含 zIndex 且 ≥ 100(高于 ScrollView 内容)
// ③ 该元素位于根 View 直接子层——data-node-id 不出现在任何 ScrollView 开闭区间内
// (ScrollView 内的 absolute 相对内容容器定位,滚动时跟着动,无法承载"贴屏"语义)
// 判不了降 warning: ScrollView 开闭数不平衡(文本区间法失效)时该文件的 ③ 判定降 warning
// 跳过: hidden / baked / templateDup;无 styleKey 交 R21;zIndex 动态值 unparseable 跳过 ② 判定
import { findProperty, allRuleBodies, getNumericAcross } from '../lib/styleMatch.mjs';
import { scrollTags, scrollViewIntervals } from '../lib/rnTags.mjs';
export const id = 'R01';
export const name = 'fixed-position';
const Z_INDEX_MIN = 100;
export function check({ cache, product, config, classMap }) {
const hits = [];
const sTags = scrollTags(config);
const helperName = (config.unit && config.unit.responsive && config.unit.responsive.helperName) || 'rpx';
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 keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯 → R21 统一报
// ① position: 'absolute'
const pos = findProperty(product.style, keys, /position\s*:\s*['"]absolute['"]/);
if (!pos.hit) {
hits.push(mk(nodeId, node, "style 含 position: 'absolute'(RN 无 position:fixed,贴屏 = 根 View 直接子层 + absolute)", "style 未含 position: 'absolute'", pos.firstRel, pos.firstLine, pos.firstSnippet));
}
// ② zIndex ≥ 100
const bodies = allRuleBodies(product.style, keys);
const z = getNumericAcross(bodies, 'zIndex', helperName);
if (z == null) {
hits.push(mk(nodeId, node, `style 含 zIndex ≥ ${Z_INDEX_MIN}(高于 ScrollView 内容)`, '未写 zIndex', '(style)', 0, ''));
} else if (!z.unparseable && z.value < Z_INDEX_MIN) {
hits.push(mk(nodeId, node, `zIndex ≥ ${Z_INDEX_MIN}`, `zIndex: ${z.value}(低于 fixed-* 层级下限)`, '(style)', 0, ''));
}
// ③ 不在 ScrollView 区间内(贴屏必须放根 View 直接子层)
for (const j of product.jsx) {
const esc = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const idRe = new RegExp(`data-node-id=["']${esc}["']`, 'g');
let m;
while ((m = idRe.exec(j.content)) !== null) {
const intervals = scrollViewIntervals(j.content, sTags);
if (intervals === null) {
hits.push({ ...mk(nodeId, node, 'fixed-* 位于根 View 直接子层(ScrollView 外)', `${j.rel} 中 ${sTags.join('/')} 开闭数不平衡,区间法判不了,请人工复核该 fixed-* 位置`, j.rel, 0, ''), severity: 'warning' });
break;
}
const inside = intervals.some(([s, e]) => m.index > s && m.index < e);
if (inside) {
const line = j.content.slice(0, m.index).split('\n').length;
hits.push(mk(nodeId, node, 'fixed-* 一律放 ScrollView 外、根 View 直接子层(RN 内 absolute 会跟内容滚)', `data-node-id 出现在 ${sTags.join('/')} 区间内`, j.rel, line, ''));
}
}
}
}
return hits;
function mk(nodeId, node, expected, actual, file, line, snippet) {
return { rule: id, nodeId, name: node.name, type: node.type, expected, actual, file: file || '(style)', line: line || 0, snippet: snippet || '' };
}
}
// R02 fills-image(rn 版,改写——判定骨架同 h5,引用形态换 RN)
// 触发: node.fills[].some(f => f.type === 'IMAGE' && f.visible !== false)
// 期望: assets.txt 有该 nodeId 切图记录 且 产物引用该切图
// (RN 引用形态: <Image data-node-id> / require('...png') / source={{uri}} / ${ASSET_PREFIX};
// nodeId 或其 `-` 归一形出现在 jsx/style 即算引用——与 h5 同口径)
// 排斥: x- 前缀忽略;baked/hidden/templateDup 跳过(禁 DOM 交 R17)
import fs from 'node:fs';
import path from 'node:path';
import { collectRuleBodies } from '../lib/styleMatch.mjs';
export const id = 'R02';
export const name = 'fills-image';
export function check({ cache, product, classMap }) {
const violations = [];
const ignorePrefix = 'x-';
const assetsPath = path.join(product.root, 'assets.txt');
const assetsText = fs.existsSync(assetsPath) ? fs.readFileSync(assetsPath, 'utf8') : '';
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!Array.isArray(node.fills) || node.fills.length === 0) continue;
const hasImage = node.fills.some((f) => f && f.type === 'IMAGE' && f.visible !== false);
if (!hasImage) continue;
if (node.name && node.name.startsWith(ignorePrefix)) continue;
if (node._inBakedSubtree) continue;
if (node._hidden) continue;
if (node._templateDup) continue;
const inAssets = assetsText.includes(nodeId);
const productMention = mentionsNodeIdAsset(product, nodeId, classMap);
if (!inAssets && !productMention.hit) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: 'assets.txt 有此 nodeId 切图记录 且 产物引用该切图',
actual: 'assets.txt 未记录 且 产物中未找到该 nodeId 相关 <Image> / require / uri 引用',
file: '(missing)',
line: 0,
snippet: '',
});
continue;
}
if (!productMention.hit) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: '产物 jsx 或 styles 引用该 nodeId 切图',
actual: 'assets.txt 已记录但产物未引用',
file: '(missing in product)',
line: 0,
snippet: '',
});
}
}
return violations;
}
function mentionsNodeIdAsset(product, nodeId, classMap) {
const idNorm = nodeId.replace(/:/g, '-');
for (const j of product.jsx) {
if (j.content.includes(nodeId) || j.content.includes(idNorm)) return { hit: true };
}
const keys = classMap[nodeId] || [];
for (const key of keys) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, key)) {
if (/require\s*\(/.test(r.body) || /\buri\s*:/.test(r.body)) return { hit: true };
}
}
}
for (const s of product.style) {
if (s.content.includes(nodeId) || s.content.includes(idNorm)) return { hit: true };
}
return { hit: false };
}
// R03 implicit-image(rn 版,改写(小)——触发判定与 h5 完全一致,引用形态换 RN)
// 触发: 无任何前缀 + 子树纯几何/容器 + 无 TEXT/INSTANCE/COMPONENT + 无 btn-/input-/sub-/block- 子节点
// 且子树含 ≥3 个「真矢量路径」(VECTOR/BOOLEAN_OPERATION/STAR/REGULAR_POLYGON,RN 更难还原) → 该整体切图
// 期望: assets.txt 有切图记录 且 产物引用(<Image>/require/uri)
// 保守: RECTANGLE/ELLIPSE/LINE 等可 style 化的简单形状不计入「必切」信号;阈值 ≥3 真矢量
import fs from 'node:fs';
import path from 'node:path';
import { collectRuleBodies } from '../lib/styleMatch.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;
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} 个矢量路径,RN style 无法还原) + 产物引用`,
actual: 'assets.txt 无切图记录 且 产物未引用该 nodeId 的 <Image> / require / uri',
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));
}
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 keys = classMap[nodeId] || [];
for (const key of keys) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, key)) {
if (/require\s*\(/.test(r.body) || /\buri\s*:/.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(rn 版,语义变更——RN 无 background-clip:text,校验目标改为「退化正确性」)
// 触发: TEXT 节点,fills 非空,末位可见 fill 是 GRADIENT_*/IMAGE
// 期望(按 RN 特性退化表):
// ① 产物 Text color 等于渐变首 stop 色值(rgba 归一化后 RGB 三通道各容差 1/255,规避舍入误判);
// 末位是 IMAGE(图案字)时无首 stop,不比色值,只查 ②
// ② assets.txt 有该 nodeId 的 `[退化告警]` 行(退化必须留痕,QA 段可复核)
// 违反: 产物写了首 stop 之外的臆造纯色 / 缺退化告警行
// 跳过: baked/hidden/templateDup;无 styleKey 交 R21;color 动态值 unparseable 只查 ②
import fs from 'node:fs';
import path from 'node:path';
import { collectRuleBodies } from '../lib/styleMatch.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 = [];
const assetsPath = path.join(product.root, 'assets.txt');
const assetsText = fs.existsSync(assetsPath) ? fs.readFileSync(assetsPath, 'utf8') : '';
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 || node._hidden || node._templateDup) continue;
const lastVisible = pickLastVisibleFill(node.fills);
if (!lastVisible) continue;
if (!GRADIENT_TYPES.has(lastVisible.type)) continue; // SOLID → R06
const keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯 → R21
// ② 退化告警留痕:assets.txt 必须有含该 nodeId 的 [退化告警] 行
const degradeLogged = assetsText
.split('\n')
.some((l) => l.includes('[退化告警]') && l.includes(nodeId));
if (!degradeLogged) {
violations.push(mk(nodeId, node, `assets.txt 含该 nodeId 的 [退化告警] 行(渐变/图案字在 RN 退化为纯色,必须留痕)`, 'assets.txt 无该 nodeId 的 [退化告警] 记录', '(assets.txt)', 0, ''));
}
// ① 首 stop 色值比对(仅 GRADIENT_* 有 stop;IMAGE 图案字跳过)
const firstStop = lastVisible.type !== 'IMAGE' ? pickFirstStop(lastVisible) : null;
if (!firstStop) continue;
const expected = toRgb(firstStop.color);
if (!expected) continue;
const declared = extractColor(product.style, keys);
if (declared === null) {
violations.push(mk(nodeId, node, `color 为渐变首 stop 色值 ${fmtRgb(expected)}(退化表:GRADIENT 退化为第一个 stop)`, 'styles 未声明 color', '(style)', 0, ''));
continue;
}
if (declared === 'unparseable') continue; // 动态色值保守跳过,留痕已由 ② 保证
if (!rgbClose(declared, expected)) {
violations.push(mk(nodeId, node, `color = 首 stop ${fmtRgb(expected)}`, `color = ${fmtRgb(declared)}(首 stop 之外的臆造纯色)`, '(style)', 0, ''));
}
}
return violations;
function mk(nodeId, node, expected, actual, file, line, snippet) {
return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file, line, snippet };
}
}
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;
}
function pickFirstStop(fill) {
const stops = fill.gradientStops;
return Array.isArray(stops) && stops.length ? stops[0] : null;
}
// Figma color {r,g,b,a∈0..1} → [r,g,b]∈0..255
function toRgb(color) {
if (!color) return null;
return [
Math.round((color.r || 0) * 255),
Math.round((color.g || 0) * 255),
Math.round((color.b || 0) * 255),
];
}
// 从 styles 取 color 声明,归一化为 [r,g,b];支持 '#hex' 与 'rgba(r,g,b,a)' 字符串。
// 返回 [r,g,b] | null(未声明) | 'unparseable'(动态/无法归一)
function extractColor(styleFiles, keys) {
let found = null;
for (const key of keys) {
for (const s of styleFiles) {
for (const r of collectRuleBodies(s.content, key)) {
const m = r.body.match(/(?:^|[,{\s])color\s*:\s*([^,\n}]+)/);
if (!m) continue;
const raw = m[1].trim();
const hex = raw.match(/^['"]#([0-9a-fA-F]{3,8})['"]$/);
if (hex) { found = hexToRgb(hex[1]); continue; }
const rgba = raw.match(/^['"]rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgba) { found = [+rgba[1], +rgba[2], +rgba[3]]; continue; }
found = 'unparseable';
}
}
}
return found;
}
function hexToRgb(h) {
let x = h.toLowerCase();
if (x.length === 3 || x.length === 4) x = x.slice(0, 3).split('').map((c) => c + c).join('');
if (x.length === 8) x = x.slice(0, 6);
if (x.length !== 6) return 'unparseable';
return [parseInt(x.slice(0, 2), 16), parseInt(x.slice(2, 4), 16), parseInt(x.slice(4, 6), 16)];
}
function rgbClose(a, b) {
return Array.isArray(a) && Array.isArray(b) && a.every((v, i) => Math.abs(v - b[i]) <= 1);
}
function fmtRgb(c) {
return Array.isArray(c) ? `rgb(${c.join(',')})` : String(c);
}
// R05 space-between(rn 版,改写——属性名换 camelCase 字符串值)
// 触发: primaryAxisAlignItems === 'SPACE_BETWEEN'(Figma AutoLayout)
// 期望: style 含 justifyContent: 'space-between'
// 反向 warning: margin*: 'auto' 模拟法在 RN 无效(RN 不支持 auto margin 撑开),单独报 warning
// 跳过: baked/hidden/templateDup;无 styleKey 交 R21
import { findProperty, allRuleBodies } from '../lib/styleMatch.mjs';
export const id = 'R05';
export const name = 'space-between';
export function check({ cache, product, classMap }) {
const hits = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node.primaryAxisAlignItems !== 'SPACE_BETWEEN') continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯 → R21
const found = findProperty(product.style, keys, /justifyContent\s*:\s*['"]space-between['"]/);
if (!found.hit) {
hits.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: "justifyContent: 'space-between'(Figma primaryAxisAlignItems=SPACE_BETWEEN)",
actual: "style 未含 justifyContent: 'space-between'",
file: found.firstRel || '(missing in style)',
line: found.firstLine || 0,
snippet: found.firstSnippet || '',
});
}
// margin auto 模拟法在 RN 无效,发现即 warning(不阻断,提醒改回 space-between)
const bodies = allRuleBodies(product.style, keys);
if (bodies.some((b) => /margin\w*\s*:\s*['"]auto['"]/.test(b))) {
hits.push({
rule: id,
severity: 'warning',
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: "space-between 用 justifyContent 表达",
actual: "style 出现 margin*: 'auto'(RN 不支持 auto margin 撑开,该写法无效)",
file: '(style)',
line: 0,
snippet: '',
});
}
}
return hits;
}
// R06 text-solid-last(rn 版,改写——color 为字符串字面量,兼容 0x 数字色)
// 触发: TEXT 节点,fills 数组非空,末位可见 fill 是 SOLID
// 期望: style 含 color: '#hex'(与 SOLID.color 匹配;0xAARRGGBB 数字色一并识别)
// 排斥: 末位可见 fill 是 GRADIENT/IMAGE → 归 R04
// 跳过: baked/hidden/templateDup;无 styleKey 交 R21;动态色值 unparseable 跳过
import { collectRuleBodies } from '../lib/styleMatch.mjs';
export const id = 'R06';
export const name = 'text-solid-last';
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;
if (node._hidden) continue;
if (node._templateDup) continue;
const lastVisible = pickLastVisibleFill(node.fills);
if (!lastVisible) continue;
if (lastVisible.type !== 'SOLID') continue; // GRADIENT/IMAGE → R04
const expectedHex = rgbaToHex(lastVisible.color);
if (!expectedHex) continue;
const keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯 → R21
let ok = false;
let sawUnparseable = false;
let hitFile = null;
let hitLine = 0;
let hitSnippet = '';
let actualColor = null;
for (const key of keys) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, key)) {
const found = extractHexColor(r.body);
if (found === 'unparseable') { sawUnparseable = true; continue; }
if (found) {
if (found === expectedHex) { ok = true; break; }
if (!actualColor) actualColor = found;
}
if (!hitFile) { hitFile = s.rel; hitLine = r.line; hitSnippet = r.body.slice(0, 200); }
}
if (ok) break;
}
if (ok) break;
}
if (!ok && sawUnparseable && !actualColor) continue; // 只有动态色值 → 保守跳过
if (!ok) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `style 含 color: '${expectedHex}'(源自 fills 末位可见 SOLID)`,
actual: actualColor ? `color: '${actualColor}'(与 SOLID 不符)` : 'style 未含 color',
file: hitFile || '(missing in style)',
line: hitLine,
snippet: hitSnippet,
});
}
}
return violations;
}
// 从规则体取 color 声明并归一为 #rrggbb;支持 '#hex' 字符串与 0xAARRGGBB 数字色。
// 返回 '#rrggbb' | null(未声明) | 'unparseable'(动态值)
function extractHexColor(body) {
const m = body.match(/(?:^|[,{\s])color\s*:\s*([^,\n}]+)/);
if (!m) return null;
const raw = m[1].trim();
const str = raw.match(/^['"](#[0-9a-fA-F]{3,8})['"]$/);
if (str) return normalizeHex(str[1]);
const num = raw.match(/^0x([0-9a-fA-F]{8})$/);
if (num) return ('#' + num[1].slice(2)).toLowerCase(); // 0xAARRGGBB → #rrggbb
return 'unparseable';
}
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;
}
function rgbaToHex(color) {
if (!color) return null;
const r = Math.round((color.r || 0) * 255);
const g = Math.round((color.g || 0) * 255);
const b = Math.round((color.b || 0) * 255);
return ('#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')).toLowerCase();
}
function normalizeHex(hex) {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
} else if (h.length === 9) {
h = h.slice(0, 7);
}
return h;
}
// R08 bg-landing-form(rn 版,语义变更——RN 无 background-image,bg- 是独立 Image 层契约)
// 触发: node.name.startsWith('bg-') 或 name === 'bg'
// 期望(SKILL §4.1.1 bg- 铺满层契约):
// ① 该 nodeId 在 jsx 中落在图片家族标签上(Image/ImageBackground/FastImage + tagMap.Image)
// ② style 含 position: 'absolute'
// ③ width/height 为数值(rpx)固定尺寸——Figma 事实尺寸,数值精度由 R23 对账
// (禁 '100%' 与 absoluteFillObject 由 RN03 专责,本条不重复报)
// 跳过: baked(祖先也是 bg-/img-)/hidden/templateDup;无 styleKey 交 R21
import { findProperty, allRuleBodies, getNumericAcross } from '../lib/styleMatch.mjs';
import { imageTags, findImageTagWithNodeId } from '../lib/rnTags.mjs';
export const id = 'R08';
export const name = 'bg-landing-form';
export function check({ cache, product, config, classMap }) {
const violations = [];
const tags = imageTags(config);
const helperName = (config.unit && config.unit.responsive && config.unit.responsive.helperName) || 'rpx';
for (const [nodeId, node] of Object.entries(cache.nodes)) {
const nm = (node.name || '').trim();
if (!(nm.startsWith('bg-') || nm === 'bg')) continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯 → R21
// ① 落在图片家族标签上
const tagHits = product.jsx.flatMap((j) => findImageTagWithNodeId(j.content, tags, nodeId).map((h) => ({ ...h, rel: j.rel })));
if (tagHits.length === 0) {
violations.push(mk(nodeId, node, `bg- 在 RN 落地为独立 <${tags[0]}>(挂父容器内头部)`, `jsx 中该 nodeId 未出现在 ${tags.join('/')} 标签上(可能被写成空 View / 背景被省略)`, '(jsx)', 0, ''));
continue; // 标签形态都不对,后续 style 判定意义不大
}
// ② position: 'absolute'
const pos = findProperty(product.style, keys, /position\s*:\s*['"]absolute['"]/);
if (!pos.hit) {
violations.push(mk(nodeId, node, "bg- 铺满层 style 含 position: 'absolute' + top: 0, left: 0", "style 未含 position: 'absolute'", pos.firstRel, pos.firstLine, pos.firstSnippet));
}
// ③ width/height 数值固定尺寸(数值精度归 R23,这里只判「声明了且可解析」)
const bodies = allRuleBodies(product.style, keys);
for (const prop of ['width', 'height']) {
const v = getNumericAcross(bodies, prop, helperName);
if (v == null) {
violations.push(mk(nodeId, node, `bg- 铺满层 ${prop} 为 Figma 事实固定尺寸(rpx 数值)`, `style 未声明数值 ${prop}(用 '100%'/absoluteFillObject 在父 minHeight 下会塌陷,见 RN03)`, '(style)', 0, ''));
}
// unparseable(如 '100%')交 RN03 专责,不在此双报
}
}
return violations;
function mk(nodeId, node, expected, actual, file, line, snippet) {
return { rule: id, nodeId, name: node.name, type: node.type, expected, actual, file: file || '(style)', line: line || 0, snippet: snippet || '' };
}
}
// R09 btn-bgc-取值(rn 版,语义变更——RN 无 CSS gradient,二选一合法)
// 触发: btn- 节点子树含 bgc- 子层,且 bgc- 末位可见 fill 是 GRADIENT_*
// 期望(二选一):
// ① 产物引用 LinearGradient 组件(import 存在 且 jsx 出现 <LinearGradient)
// ② 按退化表落首 stop 纯色 backgroundColor(btn/bgc 任一 key),且 assets.txt 有
// btn 或 bgc nodeId 的 [退化告警] 行
// 保守: ① 的「该节点区间出现」用全文件级判定(文本区间法对非自闭合标签不可靠,宁漏报);
// 首 stop 色值比对 rgba 归一化、RGB 三通道容差 1/255
// 跳过: baked/hidden/templateDup;btn 与 bgc 均无 styleKey 交 R21
import fs from 'node:fs';
import path from 'node:path';
import { collectRuleBodies } from '../lib/styleMatch.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 = [];
const assetsPath = path.join(product.root, 'assets.txt');
const assetsText = fs.existsSync(assetsPath) ? fs.readFileSync(assetsPath, 'utf8') : '';
// ① 全文件级 LinearGradient 判定(import + 标签同时出现)
const jsxAll = product.jsx.map((j) => j.content).join('\n');
const hasLinearGradient = /import[^;]*LinearGradient[^;]*from/.test(jsxAll) && /<LinearGradient\b/.test(jsxAll);
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;
const keys = [...(classMap[nodeId] || []), ...(bgc.id ? classMap[bgc.id] || [] : [])];
if (keys.length === 0) continue; // 不可追溯 → R21
if (hasLinearGradient) continue; // ① 满足
// ② 退化路径:首 stop 纯色 backgroundColor + 退化告警行
const expected = toRgb(pickFirstStop(last));
const declared = extractBgColor(product.style, keys);
const colorOk = declared === 'unparseable' || (Array.isArray(declared) && Array.isArray(expected) && rgbClose(declared, expected));
const degradeLogged = assetsText
.split('\n')
.some((l) => l.includes('[退化告警]') && (l.includes(nodeId) || (bgc.id && l.includes(bgc.id))));
if (declared == null) {
violations.push(mk(nodeId, node, `LinearGradient 组件 或 首 stop 纯色 backgroundColor + assets.txt [退化告警] 行(源自 bgc- 末位 ${last.type})`, '产物无 LinearGradient,也无 backgroundColor(渐变按钮视觉丢失)', last));
continue;
}
if (!colorOk) {
violations.push(mk(nodeId, node, `退化 backgroundColor = 渐变首 stop ${fmtRgb(expected)}`, `backgroundColor = ${fmtRgb(declared)}(首 stop 之外的臆造纯色)`, last));
}
if (!degradeLogged) {
violations.push(mk(nodeId, node, 'assets.txt 含 btn/bgc nodeId 的 [退化告警] 行(渐变→纯色退化必须留痕)', 'assets.txt 无 [退化告警] 记录', last));
}
}
return violations;
function mk(nodeId, node, expected, actual, last) {
return { rule: id, nodeId, name: node.name, type: node.type, expected, actual, file: '(style)', line: 0, snippet: `bgc last fill: ${last.type}` };
}
}
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;
}
function pickFirstStop(fill) {
const stops = fill && fill.gradientStops;
return Array.isArray(stops) && stops.length ? stops[0] : null;
}
function toRgb(stop) {
const color = stop && stop.color;
if (!color) return null;
return [
Math.round((color.r || 0) * 255),
Math.round((color.g || 0) * 255),
Math.round((color.b || 0) * 255),
];
}
// 取 backgroundColor 声明并归一为 [r,g,b];返回 [r,g,b] | null | 'unparseable'
function extractBgColor(styleFiles, keys) {
let found = null;
for (const key of keys) {
for (const s of styleFiles) {
for (const r of collectRuleBodies(s.content, key)) {
const m = r.body.match(/backgroundColor\s*:\s*([^,\n}]+)/);
if (!m) continue;
const raw = m[1].trim();
const hex = raw.match(/^['"]#([0-9a-fA-F]{3,8})['"]$/);
if (hex) { found = hexToRgb(hex[1]); continue; }
const rgba = raw.match(/^['"]rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgba) { found = [+rgba[1], +rgba[2], +rgba[3]]; continue; }
found = 'unparseable';
}
}
}
return found;
}
function hexToRgb(h) {
let x = h.toLowerCase();
if (x.length === 3 || x.length === 4) x = x.slice(0, 3).split('').map((c) => c + c).join('');
if (x.length === 8) x = x.slice(0, 6);
if (x.length !== 6) return 'unparseable';
return [parseInt(x.slice(0, 2), 16), parseInt(x.slice(2, 4), 16), parseInt(x.slice(4, 6), 16)];
}
function rgbClose(a, b) {
return a.every((v, i) => Math.abs(v - b[i]) <= 1);
}
function fmtRgb(c) {
return Array.isArray(c) ? `rgb(${c.join(',')})` : String(c);
}
// R12 flat-mode-naming(rn 版,改写——className 冲突换 StyleSheet key 冲突)
// 触发: config.merge.mode === 'flat'(所有 block 产物合并到一个 styles 命名空间)
// 期望: 同一 styleKey 不被重复定义 ≥2 次(跨文件合并后 JS 对象后键覆盖前键,危害同 CSS 覆盖)
// 保守: 统计范围 = 全部 styles 文件的 StyleSheet.create 顶层 key;
// config 无 merge.mode 或 ≠ flat → 直接放行(安全降级)
import { listStyleKeys } from '../lib/styleMatch.mjs';
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(); // key -> [{ rel, line }]
for (const s of product.style) {
for (const { key, line } of listStyleKeys(s.content)) {
if (!counts.has(key)) counts.set(key, []);
counts.get(key).push({ rel: s.rel, line });
}
}
const violations = [];
for (const [key, occ] of counts) {
if (occ.length >= 2) {
violations.push({
rule: id,
nodeId: '(n/a)',
name: `styles.${key}`,
type: 'StyleSheet',
expected: `flat 模式下 styleKey 唯一;${key} 应带 block 前缀区分(如 topbar${cap(key)})`,
actual: `styles.${key} 被定义 ${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(rn 版,改写——z-index 换 camelCase zIndex 数字属性)
// 触发: ≥2 个 fixed- 节点(可追溯、非 baked/hidden)
// 期望: 各 fixed 有 zIndex 且不全相同(层级可区分)
// 保守: 只报「全部缺 zIndex」或「全部 zIndex 相同」;不强求具体递增序;单个 fixed → 不判;
// 动态值 unparseable 视为「有值但不可比」,该节点不计入全缺/全同统计
import { allRuleBodies, getNumericAcross } from '../lib/styleMatch.mjs';
export const id = 'R14';
export const name = 'fixed-z-index';
export function check({ cache, product, config, classMap }) {
const helperName = (config.unit && config.unit.responsive && config.unit.responsive.helperName) || 'rpx';
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 keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯 → R21
fixed.push({ nodeId, node, keys });
}
if (fixed.length < 2) return [];
const zvals = fixed.map((f) => {
const r = getNumericAcross(allRuleBodies(product.style, f.keys), 'zIndex', helperName);
if (r == null) return { ...f, z: null };
if (r.unparseable) return { ...f, z: 'dynamic' };
return { ...f, z: r.value };
});
const comparable = zvals.filter((v) => v.z !== 'dynamic');
if (comparable.length < 2) return []; // 大多为动态值 → 判不了,保守放行
const allMissing = comparable.every((v) => v.z === null);
const present = comparable.filter((v) => v.z !== null).map((v) => v.z);
const allSame = present.length === comparable.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: comparable[0].nodeId,
name: comparable[0].node.name,
type: comparable[0].node.type,
expected: '多个 fixed- 元素 zIndex 应存在且不全相同(层级可区分)',
actual: allMissing ? `全部 fixed- 未设 zIndex: ${list}` : `全部 fixed- zIndex 相同: ${list}`,
file: '(style)',
line: 0,
snippet: '',
}];
}
// R16 no-flatten-text(rn 版,改写——<img> 标签集换 RN 图片家族 + adapter 感知)
// 触发: GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT,且节点 name 前缀不在白名单
// 白名单: img- / bg-(含裸词 img / bg)
// 反查: 产物 jsx 中出现「图片家族标签 + data-node-id=<该节点>」→ 违规
// 标签集 = Image/ImageBackground/FastImage + config.adapter.tagMap.Image 映射值
// 语义: 禁止用整体切图替代含 TEXT 的容器;整体导出的图无法承载动态数据,业务侧完全无救
import { imageTags, findImageTagWithNodeId } from '../lib/rnTags.mjs';
export const id = 'R16';
export const name = 'no-flatten-text';
const WHITELIST_PREFIXES = ['img-', 'bg-'];
const WHITELIST_BARE = ['img', 'bg'];
const CONTAINER_TYPES = new Set(['GROUP', 'FRAME', 'COMPONENT', 'INSTANCE']);
export function check({ cache, product, config }) {
const violations = [];
const tags = imageTags(config);
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node.type || !CONTAINER_TYPES.has(node.type)) continue;
if (!node.name) continue;
if (isWhitelisted(node.name)) continue;
if (!subtreeHasText(node, cache.nodes)) continue;
for (const j of product.jsx) {
for (const hit of findImageTagWithNodeId(j.content, tags, nodeId)) {
violations.push({
rule: id,
nodeId,
name: node.name,
type: node.type,
expected: `不得对含 TEXT 的 ${node.type}(前缀非 img-/bg-)整体切图;应按 §4.3 前缀规则拆解 TEXT / btn / img / bg 子节点`,
actual: `产物 jsx 出现 <${tags.join('|')} data-node-id="${nodeId}">,该容器被整体烤成位图`,
file: j.rel,
line: hit.line,
snippet: hit.snippet,
});
}
}
}
return violations;
}
function isWhitelisted(nodeName) {
const name = nodeName.trim();
if (WHITELIST_BARE.includes(name)) return true;
for (const p of WHITELIST_PREFIXES) {
if (name.startsWith(p) && name.length > p.length) return true;
}
return false;
}
function subtreeHasText(root, allNodes) {
const stack = [root];
const visited = new Set();
while (stack.length) {
const cur = stack.pop();
if (!cur || !cur.id || visited.has(cur.id)) continue;
visited.add(cur.id);
if (cur.type === 'TEXT') return true;
if (Array.isArray(cur.children)) {
for (const child of cur.children) {
if (child && child.id && allNodes[child.id]) {
stack.push(allNodes[child.id]);
} else {
stack.push(child);
}
}
}
}
return false;
}
// R17 no-baked-dom(v1.2.0 对账新增)
// 触发: 节点处于 bg-/bgc-/img-/x- 整体切图子树内(_inBakedSubtree=true)
// 期望: 该节点的像素已烤进父层切图(或被 x- 整体忽略),产物中【不得】再有其 data-node-id 元素
// 违反: 产物 JSX 出现 data-node-id="<nodeId>" → 双重渲染(文字/图叠一遍,典型 test13 title-text/subtitle 既进 main.png 又出 DOM)
//
// 与 R02/R06 的分工: R02/R06 跳过 baked 子孙(不逐个溯源),"禁 DOM" 由本条正向兜底。
export const id = 'R17';
export const name = 'no-baked-dom';
export function check({ cache, product }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node._inBakedSubtree) continue;
if (node._templateDup) continue; // 数据副本,代表项报过即可,避免重复
// x- 忽略子树里本就不该出现,bg-/bgc-/img- 烤进切图更不该;隐藏与否都不应出 DOM
const hit = findDomNode(product, nodeId);
if (hit) {
const bakedByNode = node._bakedBy ? cache.nodes[node._bakedBy] : null;
const bakedByName = (bakedByNode && bakedByNode.name) || node._bakedBy || '?';
const isIgnored = /^x[-]?/.test(String(bakedByName).trim()) || String(bakedByName).trim() === 'x';
const kind = isIgnored
? `处于 x- 忽略子树内(bakedBy=${bakedByName}),该内容被整体忽略,不该渲染`
: `处于整体切图子树内(bakedBy=${bakedByName}),像素已烤进父层 PNG`;
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `节点${kind},产物不应有其 data-node-id 元素`,
actual: `产物 ${hit.rel} 出现 data-node-id="${nodeId}"(${isIgnored ? '被忽略内容却出 DOM' : '双重渲染:切图一份 + DOM 一份'})`,
file: hit.rel,
line: hit.line,
snippet: hit.snippet,
});
}
}
return violations;
}
function findDomNode(product, nodeId) {
const idNorm = nodeId.replace(/:/g, '-');
const re = new RegExp(`data-node-id=(?:"|\\{['"])(?:${escapeRegex(nodeId)}|${escapeRegex(idNorm)})(?:"|['"]\\})`);
for (const j of product.jsx) {
const m = j.content.match(re);
if (m) {
const line = j.content.slice(0, m.index).split('\n').length;
const lineText = j.content.split('\n')[line - 1] || '';
return { rel: j.rel, line, snippet: lineText.trim().slice(0, 200) };
}
}
return null;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// R18 flex-direction(rn 版,判定与 h5 镜像——RN flex 默认 column,web 默认 row)
// 触发: autolayout 容器(layoutMode === 'HORIZONTAL' | 'VERTICAL')
// 期望: VERTICAL → flexDirection 省略或 'column' 均合法(RN 默认即 column);
// HORIZONTAL → 必须显式 flexDirection: 'row'(或 'row-reverse'),缺失或写 column 均违规
// 违反: 方向写反 / HORIZONTAL 漏写(默认 column 会把横排竖排)
// 前置: 该节点有 style 绑定(RN 全员 flex,无 display:flex 门槛——与 h5 的差异点)
// 跳过: baked / hidden / templateDup 副本 / 无 styleKey(不可追溯,R21 兜底)/ 动态值 unparseable
import { collectRuleBodies } from '../lib/styleMatch.mjs';
export const id = 'R18';
export const name = 'flex-direction';
export function check({ cache, product, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
const lm = node.layoutMode;
if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯:本条不报,由 R21 硬拦
const body = firstBody(product.style, keys);
if (body == null) continue; // 有绑定但 styles 里找不到规则体:RN04(P1)拦 inline,本条不判
const dir = extractFlexDirection(body);
if (dir === 'unparseable') continue; // Platform.select/三元等动态方向,保守跳过
if (lm === 'VERTICAL') {
if (dir != null && dir !== 'column') {
violations.push(mk(nodeId, node, "flexDirection: 'column' 或省略(Figma layoutMode=VERTICAL;RN 默认即 column)", `flexDirection: '${dir}'(方向写反)`));
}
} else {
// HORIZONTAL:RN 默认 column,必须显式声明横向
if (dir == null) {
violations.push(mk(nodeId, node, "flexDirection: 'row'(Figma layoutMode=HORIZONTAL;RN 默认 column,不写会竖排)", '未写 flexDirection(默认 column,横向布局会竖排)'));
} else if (dir !== 'row' && dir !== 'row-reverse') {
violations.push(mk(nodeId, node, "flexDirection: 'row'(Figma layoutMode=HORIZONTAL)", `flexDirection: '${dir}'(方向写反)`));
}
}
}
return violations;
function mk(nodeId, node, expected, actual) {
return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' };
}
}
function firstBody(styleFiles, keys) {
for (const key of keys) {
for (const s of styleFiles) {
const bodies = collectRuleBodies(s.content, key);
if (bodies.length) return bodies[0].body;
}
}
return null;
}
// 取 flexDirection 声明:'row' | 'column' | 'row-reverse' | 'column-reverse' | null(未写)| 'unparseable'(动态值)
function extractFlexDirection(body) {
const m = body.match(/flexDirection\s*:\s*([^,\n}]+)/);
if (!m) return null;
const raw = m[1].trim();
const sm = raw.match(/^['"](row|column|row-reverse|column-reverse)['"]$/);
return sm ? sm[1] : 'unparseable';
}
// R19 padding(rn 版)
// 触发: autolayout 容器且 Figma 声明了 padding(任一非 0),或产物写了 padding*
// 期望: styles 的 padding 数值(rpx 剥壳)≈ Figma paddingT/R/B/L × config.unit.scale(容差 2)
// rn 模板 unit.scale=1、rpx 参数即 Figma 原值,期望即 Figma 原值本身
// 违反:
// - Figma pad0 但产物写了非 0 padding(凭空捏造)
// - Figma 有 padding 但产物缺失或数值对不上
// RN 属性形态: SKILL 强制四边独立写(paddingTop 等 camelCase);padding / paddingVertical /
// paddingHorizontal 简写按 RN 语义参与合成(具体边覆盖简写)
// 跳过: baked / hidden / templateDup / 无 styleKey / 任一 padding 属性 unparseable(动态值,宁漏报)
import { firstRuleBody, getNumeric } from '../lib/styleMatch.mjs';
export const id = 'R19';
export const name = 'padding';
const PROPS = ['padding', 'paddingVertical', 'paddingHorizontal', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'];
export function check({ cache, product, config, classMap }) {
const violations = [];
const scale = (config && config.unit && config.unit.scale) || 1; // rn 模板默认 1(聚合器已强制 unit 存在)
const helper = (config && config.unit && config.unit.responsive && config.unit.responsive.helperName) || 'rpx';
const TOL = 2;
for (const [nodeId, node] of Object.entries(cache.nodes)) {
const lm = node.layoutMode;
if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue; // padding 仅在 autolayout 容器有意义
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const keys = classMap[nodeId] || [];
if (keys.length === 0) continue;
const rb = firstRuleBody(product.style, keys);
if (!rb) continue;
// Figma 期望值(未声明视为 0)
const fig = [
Math.round((node.paddingTop || 0) * scale),
Math.round((node.paddingRight || 0) * scale),
Math.round((node.paddingBottom || 0) * scale),
Math.round((node.paddingLeft || 0) * scale),
];
const figAllZero = fig.every((v) => v === 0);
const css = extractPadding(rb.body, helper); // null | 'unparseable' | [t,r,b,l]
if (css === 'unparseable') continue; // 动态值,保守跳过
if (!css) {
// 产物未写 padding:Figma 也 0 → OK;Figma 有 padding → 缺失违规
if (!figAllZero) {
violations.push(mk(nodeId, node, `padding ≈ [${fig.join(', ')}](Figma ×${scale},rpx 剥壳同域)`, '产物未写 padding'));
}
continue;
}
// 逐边比对
const bad = css.some((v, i) => Math.abs(v - fig[i]) > TOL);
if (bad) {
const reason = figAllZero ? '(Figma 四边 padding 均为 0,产物凭空加了 padding)' : '';
violations.push(mk(nodeId, node, `padding ≈ [${fig.join(', ')}](Figma ×${scale})`, `产物 padding = [${css.join(', ')}]${reason}`));
}
}
return violations;
function mk(nodeId, node, expected, actual) {
return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' };
}
}
// 从规则体合成 [top,right,bottom,left](rpx 已剥壳):
// RN 语义——具体边(paddingTop)覆盖轴简写(paddingVertical)覆盖全简写(padding)。
// 任一已声明属性 unparseable → 整体返回 'unparseable'(不误报);全部未声明 → null。
function extractPadding(body, helper) {
const val = {};
for (const p of PROPS) {
const r = getNumeric(body, p, helper);
if (r == null) continue;
if (r.unparseable) return 'unparseable';
val[p] = r.value;
}
if (Object.keys(val).length === 0) return null;
let t = 0, rr = 0, b = 0, l = 0;
if (val.padding != null) { t = rr = b = l = val.padding; }
if (val.paddingVertical != null) { t = b = val.paddingVertical; }
if (val.paddingHorizontal != null) { rr = l = val.paddingHorizontal; }
if (val.paddingTop != null) t = val.paddingTop;
if (val.paddingRight != null) rr = val.paddingRight;
if (val.paddingBottom != null) b = val.paddingBottom;
if (val.paddingLeft != null) l = val.paddingLeft;
return [t, rr, b, l].map((v) => Math.round(v));
}
// R20 absolute-position(rn 版)
// 触发: node.layoutPositioning === 'ABSOLUTE'(脱离父 autolayout 顺流,绝对定位)
// 期望: styles 必须声明 position: 'absolute'(top/left 为 0 可省数值,position 不可省——
// RN 里不写 position 的元素仍占父 flex 流位挤压兄弟,与 h5 同理);
// top ≈ (子.bbox.y − 父.bbox.y) × scale;left ≈ (子.bbox.x − 父.bbox.x) × scale(容差 4,rpx 剥壳同域)
// 与 h5 差异: 属性 camelCase + rpx 剥壳;RN 无 inset 简写,该分支删除
// 跳过: baked / hidden / templateDup / 无 styleKey / 父无 bbox / 动态值 unparseable
// fixed- 前缀走骨架分层定位(RN01/R01 域),不是 (子bbox−父bbox) 相对定位,跳过
//
// 核心哲学: 能从 bbox 精确算出的坐标,禁止靠猜 + "需人工核对" 兜底。
import { allRuleBodies, getNumericAcross } from '../lib/styleMatch.mjs';
export const id = 'R20';
export const name = 'absolute-position';
export function check({ cache, product, config, classMap }) {
const violations = [];
const scale = (config && config.unit && config.unit.scale) || 1;
const helper = (config && config.unit && config.unit.responsive && config.unit.responsive.helperName) || 'rpx';
const TOL = 4;
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node.layoutPositioning !== 'ABSOLUTE') continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
if (node.name && node.name.startsWith('fixed-')) continue;
const parent = node._parentId ? cache.nodes[node._parentId] : null;
const nb = node.absoluteBoundingBox;
const pb = parent && parent.absoluteBoundingBox;
if (!nb || !pb) continue; // 缺 bbox 无法精确计算,不误报
const keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯,交由 R21
const bodies = allRuleBodies(product.style, keys);
if (bodies.length === 0) continue;
const expLeft = Math.round((nb.x - pb.x) * scale);
const expTop = Math.round((nb.y - pb.y) * scale);
const problems = [];
// position: 'absolute' 声明本身不可省——检查该元素全部 styleKey 的全部规则体
if (!bodies.some((b) => /position\s*:\s*['"]absolute['"]/.test(b))) {
problems.push("缺 position: 'absolute'(不声明仍参与父 flex 顺流,占位挤压兄弟)");
}
const cssTop = getNumericAcross(bodies, 'top', helper);
const cssLeft = getNumericAcross(bodies, 'left', helper);
// 期望值≈0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,容忍不报;
// unparseable(动态值)→ 该坐标保守跳过;期望非 0 却缺失 / 写了值对不上 → 报。
if (cssTop == null) {
if (Math.abs(expTop) > TOL) problems.push(`缺 top(应 ${expTop},丢了真实偏移)`);
} else if (!cssTop.unparseable && Math.abs(cssTop.value - expTop) > TOL) {
problems.push(`top=${cssTop.value} 应 ${expTop}`);
}
if (cssLeft == null) {
if (Math.abs(expLeft) > TOL) problems.push(`缺 left(应 ${expLeft},丢了真实偏移)`);
} else if (!cssLeft.unparseable && Math.abs(cssLeft.value - expLeft) > TOL) {
problems.push(`left=${cssLeft.value} 应 ${expLeft}`);
}
if (problems.length) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `top≈${expTop} left≈${expLeft}((子bbox−父bbox)×${scale},rpx 剥壳同域,父=${node._parentId})`,
actual: problems.join(';'),
file: '(style)',
line: 0,
snippet: '',
});
}
}
return violations;
}
// R21 node-id-coverage(v1.2.1 对账新增)
// 触发: "应生成独立 DOM"的节点,产物 JSX 里找不到其 data-node-id
// 目的: 让 §5.1.1「data-node-id 全覆盖铁律」机械强制。没有 node-id,R06/R18/R19/R20 全都
// 绑定不到产物 → 遇空 classMap 只能 continue,bug 静默逃逸(典型 test13 small-card-top)。
// R21 正是"不可追溯"本身的硬拦截:应渲染却无 node-id = 违规。
//
// "应生成独立 DOM"的节点(满足任一):
// - TEXT 节点
// - autolayout 容器(layoutMode ∈ {HORIZONTAL, VERTICAL})
// - layoutPositioning === 'ABSOLUTE'(需 R20 校验坐标)
// - name 前缀 img- / btn- / input-(生成 <img>/<button>/<input>)
// 排斥:
// - _inBakedSubtree(bg-/img- 整体切图 或 x- 忽略子树,本就不出 DOM)
// - _hidden(不渲染)
// - _templateDup(.map() 数据副本,只需代表项挂 id)
// - name 前缀 bg- / bgc- / x-(自身不生成独立 DOM:bg/bgc 挂父,x 忽略)
//
// .map() 模板项:产物用代表项(variant a)nodeId 挂 data-node-id;R21 对代表项校验,
// 副本已被 _templateDup 跳过。
//
// v1.2.5 反向对账:产物 JSX 里每个字面量 data-node-id 必须存在于 cache——
// 不存在 = 幻觉 id(凭记忆/臆造挂 id 应付正向检查)。典型 test29:产物 33 个 id
// 有 11 个不在 cache(浅 cache + 低推理执行器编造)。表达式形式(data-node-id={x})不判。
export const id = 'R21';
export const name = 'node-id-coverage';
const NO_OWN_DOM_PREFIXES = ['bg-', 'bgc-', 'x-'];
export function check({ cache, product }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const nm = (node.name || '').trim();
if (NO_OWN_DOM_PREFIXES.some((p) => nm.startsWith(p)) || nm === 'bg' || nm === 'bgc' || nm === 'x') continue;
if (!shouldRender(node, nm)) continue;
if (!hasDomNode(product, nodeId)) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `应生成 DOM 的节点必须挂 data-node-id="${nodeId}"(§5.1.1 铁律;.map() 模板挂代表项 id),否则 R06/R18/R19/R20 无法绑定校验`,
actual: '产物 JSX 中找不到该 nodeId 的 data-node-id(不可追溯,可能漏画或漏挂 id)',
file: '(missing in jsx)',
line: 0,
snippet: '',
});
}
}
// 反向对账(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;
}
function shouldRender(node, nm) {
if (node.type === 'TEXT') return true;
if (node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL') return true;
if (node.layoutPositioning === 'ABSOLUTE') return true;
if (nm.startsWith('img-') || nm.startsWith('btn-') || nm.startsWith('input-')) return true;
return false;
}
function hasDomNode(product, nodeId) {
const idNorm = nodeId.replace(/:/g, '-');
const re = new RegExp(`data-node-id=(?:"|\\{['"])(?:${escapeRegex(nodeId)}|${escapeRegex(idNorm)})(?:"|['"]\\})`);
for (const j of product.jsx) {
if (re.test(j.content)) return true;
}
return false;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// R22 empty-visual-btn(rn 版,改写,warning 级不阻断)
// 触发: btn- 节点在产物中存在(有 styleKey),但自身与子树均无可见视觉——
// style 无 backgroundColor/borderWidth/borderColor、jsx 无图片家族标签挂载、
// 无 LinearGradient、子树无可见 TEXT、bbox 面积 > 0 → 空视觉按钮(透明热区)嫌疑
// 保守: 仅 warning——部分设计确实用透明热区叠在整图上,不能 exit 1;
// 但必须让主 agent 在 QA 段看见并复核(常见根因: cache 截断 / 该切图没切 / 漏画内容)
// 跳过: baked / hidden / templateDup / 无 styleKey
import { collectRuleBodies } from '../lib/styleMatch.mjs';
import { imageTags, findImageTagWithNodeId } from '../lib/rnTags.mjs';
export const id = 'R22';
export const name = 'empty-visual-btn';
const VISUAL_STYLE = /backgroundColor\s*:|borderWidth\s*:|borderColor\s*:/;
export function check({ cache, product, config, classMap }) {
const hits = [];
const tags = imageTags(config);
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
if (subtreeHasVisibleText(node)) continue;
const ids = collectSubtreeIds(node, nodeId);
if (ids.some((id2) => hasVisualStyle(product.style, classMap[id2] || []))) continue;
if (ids.some((id2) => product.jsx.some((j) => findImageTagWithNodeId(j.content, tags, id2).length > 0))) continue;
if (ids.some((id2) => jsxNodeNearLinearGradient(product.jsx, id2))) continue;
hits.push({
rule: id,
severity: 'warning',
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: 'btn- 节点应有可见视觉(文字/backgroundColor/边框/图片/渐变);纯透明热区须人工确认是否叠在整图上',
actual: '产物按钮无文字、无 backgroundColor/边框、无图片家族标签,疑似空视觉按钮(常见根因: cache 深度截断 / 该切图没切)',
file: '(style)',
line: 0,
snippet: '',
});
}
return hits;
}
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;
}
function collectSubtreeIds(root, 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 hasVisualStyle(styleFiles, keys) {
for (const key of keys) {
for (const s of styleFiles) {
for (const b of collectRuleBodies(s.content, key)) {
if (VISUAL_STYLE.test(b.body)) return true;
}
}
}
return false;
}
// 该 nodeId 标签所在文件同时出现 <LinearGradient → 视为渐变视觉(全文件级保守判定,宁漏报)
function jsxNodeNearLinearGradient(jsxFiles, nodeId) {
const esc = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const idRe = new RegExp(`data-node-id=["']${esc}["']`);
return jsxFiles.some((f) => idRe.test(f.content) && /<LinearGradient\b/.test(f.content));
}
// R23 size-fidelity(rn 版)
// 触发: 产物为节点显式声明了数值宽/高(含 rpx 包装),但与 cache bbox × scale 相差 > 4。
// 特判: width:1 height:1 + overflow:'hidden' 且真实 bbox 远大于 1 → 「锚点欺诈」——
// 真实尺寸缩成隐藏 View 只为骗过 R02/R21 存在性检查(h5 test28 同款手法)。
// 与 h5 差异: RN 恒为 border-box,h5 的「声明 padding 且无 box-sizing:border-box → 跳过」
// 盒模型分支删除,覆盖面比 h5 更大;数值经 rpx 剥壳同域比对。
// 保守跳过(宁漏报不误判):
// - 未声明数值宽/高('100%'/flex 驱动/未写)→ 布局驱动,不判
// - TEXT 节点(字体渲染尺寸与 bbox 天然有出入)→ 不判
// - baked / hidden / templateDup / 无 styleKey / 无 bbox / 动态值 unparseable → 不判
import { allRuleBodies, getNumericAcross } from '../lib/styleMatch.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) || 1;
const helper = (config && config.unit && config.unit.responsive && config.unit.responsive.helperName) || 'rpx';
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 keys = classMap[nodeId] || [];
if (keys.length === 0) continue;
const bodies = allRuleBodies(product.style, keys);
if (bodies.length === 0) continue;
const w = getNumericAcross(bodies, 'width', helper);
const h = getNumericAcross(bodies, 'height', helper);
const declW = w && !w.unparseable ? w.value : null;
const declH = h && !h.unparseable ? h.value : null;
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['"]/.test(b));
if (declW === 1 && declH === 1 && hasOverflowHidden && expW > 8 && expH > 8) {
violations.push(v(nodeId, node, `width:${expW} height:${expH}(bbox×${scale})`,
`width:1 height:1 + overflow:'hidden' 锚点欺诈——元素仅为骗过存在性/引用检查而存在,视觉未渲染(真实 ${expW}×${expH})`));
continue;
}
const problems = [];
if (declW != null && Math.abs(declW - expW) > TOL) problems.push(`width=${declW} 应 ${expW}`);
if (declH != null && Math.abs(declH - expH) > TOL) problems.push(`height=${declH} 应 ${expH}`);
if (problems.length) {
violations.push(v(nodeId, node, `width≈${expW} height≈${expH}(bbox×${scale},rpx 剥壳同域,容差 ${TOL})`, 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: '',
};
}
// RN01 scroll-skeleton(RN 特有,v0.3.13「rn 页面根强制骨架」代码化)
// --merge(页面级)校验:
// ① 页面 jsx 必须出现滚动容器标签(ScrollView + tagMap.ScrollView)——rn 分支不判视口,一律套骨架
// ② styles 存在 scrollContent key 时:必须用 minHeight(写死 height 即违规——内容超高会被裁)
// ③ root / scrollContent 规则体禁 overflow: 'hidden'(阻止滚动)
// ②③ 依赖 SKILL §4.1.1 固定骨架命名(root/scroll/scrollContent);key 缺失时降 warning(判不了)
// --block(反向)校验: block 产物不得含页面骨架——styles 有 scrollContent key 且 jsx 有滚动容器
// 即违规(sub-agent 派发进来的内层 block 不套骨架;scrollx-/scrolly- 的普通 ScrollView 不误伤,
// 因其 styleKey 不叫 scrollContent)
// fixed-* 不入 ScrollView 的逐节点判定由 R01 ③ 承担,本条不重复报
import { collectRuleBodies } from '../lib/styleMatch.mjs';
import { scrollTags } from '../lib/rnTags.mjs';
export const id = 'RN01';
export const name = 'scroll-skeleton';
export function check({ product, config, mode }) {
const hits = [];
const sTags = scrollTags(config);
const scrollRe = new RegExp(`<(?:${sTags.join('|')})\\b`);
const jsxHasScroll = product.jsx.some((j) => scrollRe.test(j.content));
const scrollContentBodies = collectBodies(product.style, 'scrollContent');
const rootBodies = collectBodies(product.style, 'root');
if (mode === 'merge') {
// ① 骨架存在性
if (!jsxHasScroll) {
hits.push(mk('页面根未套 ScrollView 骨架', `页面 jsx 必须为 View(root) > ${sTags[0]} > View(scrollContent) 固定骨架(rn 不判视口,一律套)`, `jsx 中未找到 ${sTags.join('/')} 标签(根 View 直接装内容,RN 的 View 天然不滚,超高内容会被裁)`));
return hits; // 骨架都没有,后续判定无意义
}
// ② scrollContent 用 minHeight 不用 height
if (scrollContentBodies.length === 0) {
hits.push({ ...mk('scrollContent key 缺失', 'styles 含 scrollContent(SKILL §4.1.1 固定骨架命名)', 'styles 未找到 scrollContent key,骨架细则判不了,请人工复核页面根结构'), severity: 'warning' });
} else {
for (const b of scrollContentBodies) {
const hasMinHeight = /minHeight\s*:/.test(b.body);
const hasHeight = /(?:^|[,{\s])height\s*:/.test(b.body);
if (hasHeight && !hasMinHeight) {
hits.push(mk('scrollContent 写死 height', 'scrollContent 用 minHeight(内容不足时至少这么高,超出自动增高)', `scrollContent 写死 height(内容超高会被裁);${b.rel}:${b.line}`));
}
if (/overflow\s*:\s*['"]hidden['"]/.test(b.body)) {
hits.push(mk('scrollContent overflow hidden', "scrollContent 禁 overflow: 'hidden'(阻止滚动)", `${b.rel}:${b.line} 出现 overflow: 'hidden'`));
}
}
}
// ③ root 禁 overflow hidden
for (const b of rootBodies) {
if (/overflow\s*:\s*['"]hidden['"]/.test(b.body)) {
hits.push(mk('root overflow hidden', "root 禁 overflow: 'hidden'(承接 fixed-*,不得裁剪)", `${b.rel}:${b.line} 出现 overflow: 'hidden'`));
}
}
} else {
// --block 反向: block 内出现页面骨架(scrollContent key + 滚动容器)即违规
if (jsxHasScroll && scrollContentBodies.length > 0) {
hits.push(mk('block 内套页面骨架', 'sub-agent 派发的 block 产物不套 ScrollView 骨架(骨架只属于页面根,由主 agent 合并时套)', `block 产物同时存在 ${sTags.join('/')} 标签与 scrollContent key(${scrollContentBodies[0].rel}:${scrollContentBodies[0].line})`));
}
}
return hits;
function mk(nameStr, expected, actual) {
return { rule: id, nodeId: '(page)', name: nameStr, type: 'SKELETON', expected, actual, file: '(product)', line: 0, snippet: '' };
}
}
function collectBodies(styleFiles, key) {
const out = [];
for (const s of styleFiles) {
for (const r of collectRuleBodies(s.content, key)) out.push({ ...r, rel: s.rel });
}
return out;
}
// RN02 flow-child-position(RN 特有,v0.3.13「顺流子位置来源硬约束」代码化)
// 触发: 父 layoutMode ∈ {HORIZONTAL, VERTICAL} 且 子 layoutPositioning !== 'ABSOLUTE'(顺流子)
// 校验(位置由父 flex 5 字段负责,子不得自带位置):
// ① 子 style 禁 position / top / left / right / bottom / margin*(逆推 bbox 绕过父 flex 语义)
// ② 子 style 的 padding* 非 0 值必须溯源到该子节点 cache 同名字段(只判「cache 无却写了」的
// 凭空捏造分支;数值精度归 R19,避免双报)
// ③ 子 style 的 flex: 1 仅当 cache layoutGrow === 1 或 layoutSizing* === 'FILL'(违反 FIXED sizing)
// 豁免: fixed- 前缀子(贴屏语义,归 R01/RN01 管);baked/hidden/templateDup;无 styleKey(R21 兜底);
// 属性动态值 unparseable 保守跳过
import { allRuleBodies, getNumericAcross } from '../lib/styleMatch.mjs';
export const id = 'RN02';
export const name = 'flow-child-position';
const BANNED_RE = /(?:^|[,{\s])(position|top|left|right|bottom|margin(?:Top|Bottom|Left|Right|Horizontal|Vertical)?)\s*:/g;
const PADDING_PROPS = ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'];
const PAD_SHORTHAND = { paddingHorizontal: ['paddingLeft', 'paddingRight'], paddingVertical: ['paddingTop', 'paddingBottom'], padding: PADDING_PROPS };
export function check({ cache, product, config, classMap }) {
const violations = [];
const helperName = (config.unit && config.unit.responsive && config.unit.responsive.helperName) || 'rpx';
for (const [, parent] of Object.entries(cache.nodes)) {
const lm = parent.layoutMode;
if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue;
if (parent._inBakedSubtree || parent._hidden) continue;
for (const child of parent.children || []) {
if (!child || typeof child !== 'object' || !child.id) continue;
const node = cache.nodes[child.id] || child;
if (node.layoutPositioning === 'ABSOLUTE') continue; // 绝对定位子 → R20
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
if (typeof node.name === 'string' && node.name.startsWith('fixed-')) continue; // 贴屏 → R01
// bg- 铺满层契约本身要求 absolute + top/left(R08/RN03 管辖),不按顺流子判
if (typeof node.name === 'string' && (node.name.startsWith('bg-') || node.name.trim() === 'bg')) continue;
const keys = classMap[node.id] || [];
if (keys.length === 0) continue; // 不可追溯 → R21
const bodies = allRuleBodies(product.style, keys);
if (bodies.length === 0) continue;
// ① 违禁位置属性(显式 0 / rpx(0) 不改变布局,保守放行;position 无数值形态,恒报)
for (const b of bodies) {
let m;
BANNED_RE.lastIndex = 0;
while ((m = BANNED_RE.exec(b)) !== null) {
if (m[1] !== 'position') {
const v = getNumericAcross([b], m[1], helperName);
if (v && !v.unparseable && v.value === 0) continue;
}
violations.push(mk(node, `顺流子(父 ${lm})位置由父 flex 负责(flexDirection/justifyContent/alignItems/gap/padding),子 style 禁 ${m[1]}`, `子 style 出现 ${m[1]}(逆推 bbox 绕过父 flex 语义,视觉整体漂移)`));
}
}
// ② padding 凭空捏造(cache 无同名字段却写了非 0 值)。
// 子自身也是 autolayout 容器时让位 R19(R19 对 autolayout 容器做全量 padding 对账,含凭空分支)
const childIsAutolayout = node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL';
for (const [prop, expands] of Object.entries(childIsAutolayout ? {} : { ...Object.fromEntries(PADDING_PROPS.map((p) => [p, [p]])), ...PAD_SHORTHAND })) {
const v = getNumericAcross(bodies, prop, helperName);
if (v == null || v.unparseable || v.value === 0) continue;
const sourced = expands.some((p) => typeof node[p] === 'number' && node[p] !== 0);
if (!sourced) {
violations.push(mk(node, `子 style 的 ${prop} 须溯源 cache 同名 padding 字段(数值精度归 R19)`, `${prop}: ${v.value} 在 cache 中无对应 padding 字段(凭空捏造)`));
}
}
// ③ flex: 1 须有 FILL 依据
const flexV = getNumericAcross(bodies, 'flex', helperName);
if (flexV && !flexV.unparseable && flexV.value === 1) {
const fillOk = node.layoutGrow === 1 || node.layoutSizingHorizontal === 'FILL' || node.layoutSizingVertical === 'FILL';
if (!fillOk) {
violations.push(mk(node, 'flex: 1 仅当 Figma layoutGrow=1 或 layoutSizing*=FILL', `flex: 1 但 cache 为 FIXED sizing(尺寸应写事实值)`));
}
}
}
}
return violations;
function mk(node, expected, actual) {
return { rule: id, nodeId: node.id, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' };
}
}
// RN03 no-percent-fill(RN 特有,v0.3.12「%-塌陷防御」代码化)
// 触发: bg- 前缀节点(含裸词 bg)
// 校验: 该节点 style 禁 width/height: '100%' 与 absoluteFillObject 引用——
// 父用 minHeight 时 '%' 引用父的计算高度(可能小于 Figma 设计稿高度)跟着塌陷;
// 必须写 Figma 事实固定尺寸 width: rpx(w), height: rpx(h) + top: 0, left: 0
// (正向契约「必须落 Image + absolute + 数值尺寸」由 R08 承担,本条只拦塌陷写法)
// 跳过: baked/hidden/templateDup;无 styleKey 交 R21
import { allRuleBodies } from '../lib/styleMatch.mjs';
export const id = 'RN03';
export const name = 'no-percent-fill';
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('bg-') || nm === 'bg')) continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const keys = classMap[nodeId] || [];
if (keys.length === 0) continue; // 不可追溯 → R21
for (const b of allRuleBodies(product.style, keys)) {
const pm = b.match(/(width|height)\s*:\s*['"](\d+(?:\.\d+)?)%['"]/);
if (pm) {
violations.push(mk(nodeId, node, `bg- 铺满层 ${pm[1]} 写 Figma 事实固定尺寸(rpx 数值)`, `${pm[1]}: '${pm[2]}%'(父 minHeight 时 % 引用父计算高度会塌陷)`));
}
if (/absoluteFillObject/.test(b)) {
violations.push(mk(nodeId, node, "bg- 铺满层用 position:'absolute' + top:0,left:0 + Figma 事实尺寸", 'style 引用 StyleSheet.absoluteFillObject(等价于全 % 铺满,父 minHeight 时塌陷)'));
}
}
}
return violations;
function mk(nodeId, node, expected, actual) {
return { rule: id, nodeId, name: node.name, type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' };
}
}
// RN04 styles-file-separation(RN 特有,v0.3.12「styles.ts 强制独立文件」代码化)
// 触发: 全产物 jsx 文件
// 校验:
// ① jsx 文件内禁出现 StyleSheet.create(样式必须在独立 styles 文件,否则响应式改写 /
// adapter 改写会触碰 JSX,且 styleMatch 引擎按独立文件对账)
// ② jsx 内禁静态 inline style 对象 style={{...}}(逃出 styleMatch 对账 → 数值规则全部失明)
// 放行: style={[styles.a, 动态变量]} 数组形态(动态成员不参与机械对账,由 nodeIdToStyleKey 忽略)
export const id = 'RN04';
export const name = 'styles-file-separation';
export function check({ product }) {
const violations = [];
for (const j of product.jsx) {
const lines = j.content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/StyleSheet\s*\.\s*create/.test(line)) {
violations.push(mk(j.rel, i + 1, line.trim(), 'StyleSheet.create 只出现在独立 styles 文件(styles.ts / *.styles.ts)', 'jsx 文件内出现 StyleSheet.create(样式与 JSX 混写)'));
}
if (/style=\{\{/.test(line)) {
violations.push(mk(j.rel, i + 1, line.trim(), 'style 一律绑定 styles.<key>(独立文件对账);动态样式用 style={[styles.x, 动态变量]} 数组形态', 'jsx 出现静态 inline style 对象 style={{...}}(逃出 styleMatch 对账,数值规则全部失明)'));
}
}
}
return violations;
function mk(file, line, snippet, expected, actual) {
return { rule: id, nodeId: '(n/a)', name: file, type: 'JSX', expected, actual, file, line, snippet: snippet.slice(0, 200) };
}
}
# R01 - fixed-position(RN 语义变更)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 与 R14 fixed-z-index 是"父规则-补充规则"关系,不排斥;骨架本体判定归 RN01
## 触发条件
- **cache**: `node.name.startsWith('fixed-')`
- **命中信号**: 图层名以 `fixed-` 开头(如 `fixed-状态栏`、`fixed-topbar`、`fixed-底部bar`)
## 期望产物(fixed-* 铁律,SKILL §4.1.1)
RN 没有 CSS `position: fixed`;`<ScrollView>` 内部的 absolute 元素相对内容容器定位,滚动时跟着动。"贴屏"只有一条路:放根 `<View>` 直接子层。三项同时满足:
1. style 含 `position: 'absolute'`
2. style 含 `zIndex` 且 ≥ 100(高于 ScrollView 内容)
3. 该元素的 data-node-id **不出现在任何 ScrollView 开闭区间内**(adapter 启用时 tagMap.ScrollView 映射值同判)
位置按 Figma constraints 换算三档:
| constraints.vertical | 写法 |
|---|---|
| `TOP`(默认) | `top: rpx(<Figma y - 顶层frame y>)` |
| `BOTTOM` | `bottom: rpx(<顶层frame 底 - 节点底>)`(常见 `bottom: 0`) |
| `CENTER` | `top: '50%'` + `transform: [{ translateY: -h/2 }]` |
## 反例 (agent 常见错法)
```ts
// ❌ 错法 1: 写在 ScrollView 内(滚动时跟内容动,贴屏语义失效)
// <ScrollView><Image style={styles.fixedNavbar} data-node-id="211:32" /></ScrollView>
// ❌ 错法 2: 缺 position
fixedNavbar: { top: 0, width: rpx(375), height: rpx(88) },
// ❌ 错法 3: 缺 zIndex(被 ScrollView 内容盖住)
fixedNavbar: { position: 'absolute', top: 0 },
```
## 落地代码模板
```tsx
// fixed-* 放根 View 直接子层(ScrollView 外)
<View style={styles.root}>
<ScrollView style={styles.scroll}>{/* 顺流内容 */}</ScrollView>
<Image source={require('./assets/fixed-navbar.png')} style={styles.fixedNavbar} data-node-id="211:32" />
</View>
```
```ts
fixedNavbar: {
position: 'absolute',
top: 0,
left: 0,
width: rpx(375),
height: rpx(88),
zIndex: 100, // 见 R14
},
```
## 违反后果
- **产物表现**: 滚动时"贴屏"元素跟随内容滚走,导航栏/底部按钮消失
- **判不了降级**: ScrollView 开闭数不平衡(文本区间法失效)时,区间判定降 warning,人工复核
## 相关
- SKILL.md §4.1.1 rn 页面根强制骨架 + fixed-* 分层
- rules/RN01-scroll-skeleton.md(骨架本体)
- rules/R14-fixed-z-index.md
# R02 - fills-image(RN 改写)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: `x-` 前缀忽略;`_inBakedSubtree`/`_hidden`/`_templateDup` 跳过(禁 DOM 交 R17);btn- 内 fills IMAGE 走 R09 优先
## 触发条件
- **cache**: `node.fills[].some(f => f.type === 'IMAGE' && f.visible !== false)`
## 期望产物
- `assets.txt` 中有该 nodeId 的切图记录(fileName)
- 产物引用该切图,RN 合法引用形态:
- `<Image source={require('./assets/xxx.png')} data-node-id="..." />`
- `source={{ uri: `${ASSET_PREFIX}xxx.png` }}`
- `<ImageBackground source={...}>` / `<FastImage source={...}>`
- 判定口径:nodeId(或其 `:` → `-` 归一形)出现在 jsx/styles,或该节点 styleKey 规则体含 `require(` / `uri:`
## 反例 (agent 常见错法)
```ts
// Figma fills = [{IMAGE, imageRef: xxx}]
// ❌ 凭空搓渐变/纯色代替切图
card: { backgroundColor: '#FFE9C8' },
// ❌ assets.txt 记了切图,产物却没引用(图被偷换成空 View)
```
## 落地代码模板
```tsx
<Image
source={require('./assets/card-bg.png')}
style={styles.cardBg}
data-node-id="211:45"
/>
```
## 违反后果
- **产物表现**: 图片内容缺失,或被"看起来差不多"的纯色/渐变冒充
- **对账关系**: 切图台账(images.json md5)与消费契约(F₁⊆F₂)的绑定起点
## 相关
- SKILL.md §4.4.0 切图复用契约
- rules/R17-no-baked-dom.md(baked 子孙禁 DOM)
- rules/R09-btn-bgc-取值.md
# R03 - implicit-image(RN 改写,极保守)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: R11 mask-vector-css-able 的常见形态被本条覆盖;任何前缀命中即不判
## 触发条件
- **cache**: 节点无任何内置前缀 + 子树纯几何/容器(GROUP/FRAME + VECTOR/BOOL/RECT/ELLIPSE/STAR/POLYGON/LINE)
- **且**: 无 TEXT/INSTANCE/COMPONENT 子层、无 btn-/input-/sub-/block- 子节点
- **且**: 子树含 **≥3 个真矢量路径**(VECTOR/BOOLEAN_OPERATION/STAR/REGULAR_POLYGON)——RN style 无法还原
- RECTANGLE/ELLIPSE/LINE 等可 style 化形状不计入"必切"信号
## 期望产物
- 该容器整体切图:assets.txt 有记录 + 产物 `<Image>` / require / uri 引用
## 反例 (agent 常见错法)
```tsx
// Figma: 无前缀装饰组合,子树 5 个 VECTOR 路径
// ❌ 逐个 VECTOR 用 View 描边近似(RN 无 SVG path 能力,视觉必然失真)
<View style={styles.deco1} /><View style={styles.deco2} />...
```
## 落地代码模板
```tsx
<Image source={require('./assets/deco-cluster.png')} style={styles.decoCluster} data-node-id="211:88" />
```
## 违反后果
- **产物表现**: 一堆矢量路径被 View 近似或直接丢失,装饰区域空白/失真
## 相关
- rules/R11-mask-vector-css-able.md(软防线,RN 可表达集更小)
- rules/R02-fills-image.md
# R04 - text-gradient(RN 语义变更:校验目标 = 退化正确性)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 末位可见 fill 是 SOLID → 归 R06;baked/hidden/templateDup 跳过;无 styleKey 交 R21
## 触发条件
- **cache**: TEXT 节点,fills 非空,末位可见 fill 是 `GRADIENT_*` 或 `IMAGE`
## 期望产物(RN 特性退化表)
RN 没有 `background-clip: text`,渐变/图案字按退化表处理,校验目标从"必须走 clip"变为"退化必须正确且留痕":
1. **色值正确**:产物 `color` 等于**渐变首 stop 色值**(归一化 rgba 元组比对,RGB 三通道各容差 1/255);末位是 IMAGE(图案字)无首 stop,不比色值
2. **退化留痕**:`assets.txt` 必须有该 nodeId 的 `[退化告警]` 行,例:
```
[退化告警] 211:56 渐变标题: GRADIENT_LINEAR 退化为首 stop #FF6600(RN 无 background-clip:text,如需真渐变字接 react-native-linear-gradient + MaskedView)
```
产物写了首 stop 之外的臆造纯色 → violation;缺告警行 → violation。
## 反例 (agent 常见错法)
```ts
// Figma: TEXT fills 末位 GRADIENT_LINEAR stops = [#FF6600 → #FF0000]
// ❌ 臆造中间色冒充渐变(既不是首 stop 也没留痕)
title: { color: '#FF3300' },
// ❌ 退化了但 assets.txt 无 [退化告警] 行(静默降级,QA 不可复核)
title: { color: '#FF6600' },
```
## 落地代码模板
```ts
// styles.ts —— 退化为首 stop
title: {
color: '#FF6600', // GRADIENT_LINEAR 首 stop;真渐变字需接三方库,见 assets.txt 退化告警
fontSize: rpx(24),
},
```
```
# assets.txt 追加
[退化告警] 211:56 渐变标题: GRADIENT_LINEAR 退化为首 stop #FF6600
```
## 违反后果
- **产物表现**: 渐变字变成随机纯色且无告警,视觉偏差静默逃逸
## 相关
- SKILL.md §4.3.rn RN 特性退化表
- rules/R06-text-solid-last.md(SOLID 归属)
- rules/R09-btn-bgc-取值.md(同为渐变退化留痕机制)
# R05 - space-between(RN 改写)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 无
## 触发条件
- **cache**: `node.primaryAxisAlignItems === 'SPACE_BETWEEN'`(Figma AutoLayout)
## 期望产物
- style 含 `justifyContent: 'space-between'`
- **RN 特别提醒**:`margin*: 'auto'` 撑开在 RN 无效(Yoga 不支持 auto margin 分配剩余空间),出现即 warning
## 反例 (agent 常见错法)
```ts
// ❌ 漏写(两端对齐退化为起点堆叠)
row: { flexDirection: 'row' },
// ❌ margin auto 模拟(web 习惯带过来,RN 无效)
rowLast: { marginLeft: 'auto' },
```
## 落地代码模板
```ts
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
```
## 违反后果
- **产物表现**: 两端对齐元素挤在起点,间距全丢
## 相关
- rules/R18-flex-direction.md(方向镜像)
- rules/RN02-flow-child-position.md(顺流子禁 margin)
# R06 - text-solid-last(RN 改写)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 末位可见 fill 是 GRADIENT/IMAGE → 归 R04;R10 不重复扫已判定的 color
## 触发条件
- **cache**: TEXT 节点,fills 数组非空,末位可见 fill 是 `SOLID`
## 期望产物
- style 含 `color: '#rrggbb'`,色值 = fills **末位可见** SOLID(多层 fills 取错层是本条主拦对象)
- 识别形态:`'#hex'` 字符串字面量(3/4/6/8 位均归一到 6 位比对)、`0xAARRGGBB` 数字色
- 动态色值(三元/变量)→ 保守跳过
## 反例 (agent 常见错法)
```ts
// Figma: fills = [{SOLID #999999, visible}, {SOLID #003366, visible}] → 末位 #003366
// ❌ 取了第一层
title: { color: '#999999' },
// ❌ 不写 color(RN Text 默认黑,与设计不符)
title: { fontSize: rpx(16) },
```
## 落地代码模板
```ts
title: {
color: '#003366', // fills 末位可见 SOLID
fontSize: rpx(16),
},
```
## 违反后果
- **产物表现**: 字色取错层/丢失,大面积文字颜色偏差
## 相关
- rules/R04-text-gradient.md(GRADIENT/IMAGE 归属)
- rules/R10-no-fake-solid-color.md(幻觉色)
# R07 - multi-fills(RN 文档改写,软防线)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**: fills 只有 1 层可见 → 不适用;全 SOLID → R06;TEXT + 末位 GRADIENT/IMAGE → R04
## 触发条件
- **cache**: `fills.filter(f => f && f.visible !== false).length >= 2` 且类型混合(SOLID+IMAGE、SOLID+GRADIENT 等)
## 期望产物
**核心原则**:每层 fills 都要落地,不能只取其一。RN 没有 CSS 多值 background,多层填充**拆层叠放**:
1. **SOLID + IMAGE**(底色 + 图案):底层 View `backgroundColor` + 上层 `<Image>` absolute 铺放
```tsx
<View style={styles.box} data-node-id="211:60">
<Image source={require('./assets/pattern.png')} style={styles.boxPattern} />
{/* 内容 */}
</View>
```
```ts
box: { backgroundColor: '#FF6600' },
boxPattern: { position: 'absolute', top: 0, left: 0, width: rpx(335), height: rpx(100) },
```
2. **SOLID + GRADIENT**:底层 `backgroundColor` + 上层 `<LinearGradient>`(项目已接该库时);未接库按退化表取上层首 stop 合成并留 `[退化告警]`
3. **层序**:Figma fills 索引小的在下、大的在上;RN 里 JSX 后写的组件在上,顺序与 fills 一致(与 h5 CSS 简写的"颠倒"相反)
## 反例 (agent 常见错法)
```ts
// Figma fills = [SOLID #FF6600, IMAGE pattern]
// ❌ 只写 SOLID 忽略 IMAGE(光斑/纹理丢失)
box: { backgroundColor: '#FF6600' },
```
## 违反后果
- **产物表现**: 底色或图案丢失,视觉与设计不符
## Rule-Scan 识别提示
- 统计 `visible !== false` 的 fills 数量,≥2 才触发
- 输出 context 里必须列**每一层**的 type + 主要参数(color/imageRef/gradientStops),UI sub-agent 照做拆层
## 相关
- rules/R09-btn-bgc-取值.md(btn 内 bgc 层的渐变退化)
- SKILL.md §4.3.rn RN 特性退化表
# R08 - bg-landing-form(RN 语义变更:bg- = 独立 Image 层契约)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: '100%'/absoluteFillObject 塌陷写法归 RN03,本条不双报;祖先也是 bg-/img-(baked)跳过
## 触发条件
- **cache**: `node.name.startsWith('bg-')` 或 `name === 'bg'`
## 期望产物
RN 没有 `background-image`,bg- 的落地形态是**独立 `<Image>` 挂父容器内头部**:
1. 该 nodeId 落在图片家族标签上(`Image`/`ImageBackground`/`FastImage` + adapter tagMap.Image 映射值)
2. style 含 `position: 'absolute'`(+ `top: 0, left: 0`)
3. `width`/`height` 为**数值(rpx)固定尺寸**——Figma 事实尺寸,数值精度由 R23 对账
4. bg- 子孙不生成组件(像素已烤进 PNG,禁 DOM 交 R17)
## 反例 (agent 常见错法)
```tsx
// ❌ 错法 1: bg- 被写成空 View,背景整体丢失
<View style={styles.bgBody} data-node-id="211:20" />
// ❌ 错法 2: 铺满层用 %(父 minHeight 时塌陷,详见 RN03)
bgBody: { width: '100%', height: '100%' },
// ❌ 错法 3: 缺 absolute(把兄弟内容挤下去)
bgBody: { width: rpx(375), height: rpx(1579) },
```
## 落地代码模板
```tsx
<View style={styles.scrollContent}>
<Image
source={require('./assets/bg-body.png')}
style={styles.bgBody}
data-node-id="211:20"
/>
{/* 顶层 frame 顺流子... */}
</View>
```
```ts
bgBody: {
position: 'absolute',
top: 0,
left: 0,
width: rpx(375),
height: rpx(1579), // Figma 事实值,不用 '100%'
},
```
## 违反后果
- **产物表现**: 页面/卡片背景丢失或塌陷,内容浮在空白上
## 相关
- SKILL.md §4.1.1 bg- 铺满层用 Figma 事实尺寸
- rules/RN03-no-percent-fill.md(塌陷写法专责)
- rules/R17-no-baked-dom.md(bg- 子孙禁 DOM)
# R09 - btn-bgc-取值(RN 语义变更:二选一合法)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: bgc 末位 SOLID → 按 backgroundColor 取,不算 R09;bgc IMAGE → 归 R02
## 触发条件
- **cache**: `btn-` 节点子树含 `bgc-` 子层,且 bgc- 末位可见 fill 是 `GRADIENT_*`
## 期望产物(二选一)
RN 没有 CSS gradient,渐变按钮两条合法路径:
1. **真渐变**:产物引用 `LinearGradient` 组件(`react-native-linear-gradient` 或项目等价库)——import 存在且 jsx 出现 `<LinearGradient`
2. **退化路径**:btn/bgc 任一 styleKey 落**首 stop 纯色** `backgroundColor`(归一化 rgba 比对,RGB 三通道容差 1/255),且 `assets.txt` 有 btn 或 bgc nodeId 的 `[退化告警]` 行
两者皆无 → violation;写了首 stop 之外的臆造纯色 → violation;退化了但没留痕 → violation。
## 反例 (agent 常见错法)
```ts
// Figma: btn-submit > bgc-grad, fills 末位 GRADIENT_LINEAR stops=[#FF8800 → #FF3300]
// ❌ 臆造中间色,无 LinearGradient 也无告警行
btnSubmit: { backgroundColor: '#FF5500' },
// ❌ 完全丢视觉(渐变按钮变透明)
btnSubmit: { borderRadius: rpx(22) },
```
## 落地代码模板
```ts
// 退化路径
btnSubmit: {
backgroundColor: '#FF8800', // GRADIENT_LINEAR 首 stop;真渐变接 LinearGradient
borderRadius: rpx(22),
},
```
```
# assets.txt 追加
[退化告警] 211:72 btn-submit/bgc-grad: GRADIENT_LINEAR 退化为首 stop #FF8800
```
## 违反后果
- **产物表现**: 渐变按钮变纯色/透明且无告警,视觉降级静默逃逸
## 相关
- rules/R04-text-gradient.md(同为渐变退化留痕机制)
- rules/R07-multi-fills.md
- SKILL.md §4.3.rn RN 特性退化表
# R10 - no-fake-solid-color(RN 文档改写,软防线)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌ (需交叉核对 cache 与产物)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**: R06 已判定的 TEXT color、R07/R09 已判定的背景色不重复扫
## 触发条件
- **产物**: styles.ts 中出现 `color: '#XXX'` / `backgroundColor: '#XXX'` 等 SOLID 色
- **cache 侧**: 该 styleKey 对应 nodeId 的所有 fills 中找不到匹配的 SOLID 色 → agent 幻觉搓色
## 期望产物
**核心原则**:产物里出现的每一个色值都必须在 cache 里能找到 fills 源头。
**判定算法**:
1. Read 产物全部 styles 文件,提取所有 `'#RRGGBB'` / `'rgba(...)'`
2. 反查:该 styleKey 挂在哪个 nodeId 下(经 jsx 的 data-node-id ↔ style 绑定)
3. Read 该 nodeId 的 cache,遍历 fills:
- 有匹配 SOLID.color → OK
- 全部 GRADIENT/IMAGE → 走 R04/R07/R09,不属 R10
- 找不到匹配 → **R10 命中(幻觉色)**
## 反例 (agent 常见错法)
```ts
// Figma nodeId=211:32 fills = [](无填充,靠父容器)
topbar: { backgroundColor: '#F5F5F5' }, // ❌ cache 里找不到 #F5F5F5
// Figma nodeId=211:411 fills = [{SOLID #003366}]
title: { color: '#0066CC' }, // ❌ #0066CC ≠ #003366
```
## Rule-Scan 识别提示
- 只对产物已 Read 后判定,靠"反向核对"
- cache fills=[] 但产物有色 → 强命中;fills 全 IMAGE/GRADIENT 但产物有 SOLID 色 → 强命中
- **豁免**: `'transparent'`、渐变退化产物已带 `[退化告警]` 行的首 stop 色(R04/R09 管辖)
## 违反后果
- **产物表现**: 颜色与设计稿不符,agent "猜"一个相近色
## 相关
- rules/R06-text-solid-last.md
- rules/R07-multi-fills.md
- rules/R09-btn-bgc-取值.md
# R11 - mask-vector-css-able(RN 文档改写,软防线;RN 可表达集更小)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**: 已按 R02(fills=IMAGE)/R03(implicit-image)切图 → 不重复判;简单矩形/圆角矩形/圆形 → style 可表达,不切
## 触发条件
- **cache**: 节点或其子树含 `BOOLEAN_OPERATION` / 多层 `VECTOR` 叠加 / `isMask === true` 组合 / 复杂 path
- **且**: 该结构不能仅用 RN style 表达
## 期望产物
**核心原则**:RN 可表达集比 CSS 更小——没有 `mask` / `clip-path` / 多值 background / SVG path 原生能力,**复合几何基本一律切图**,判"该切图"的门槛比 h5 更低。
- `<Image source={require('...')} style={styles.foo} data-node-id="{id}" />`
- style 只写尺寸与定位,禁止试图用 borderRadius 组合近似复合几何
## RN 可表达 vs 不可表达速查
**可 style 表达(不切图)**:
- 纯色矩形 / 圆角矩形 → `backgroundColor` + `borderRadius`
- 圆形 / 椭圆 → `borderRadius`(取宽高一半)
- 阴影 → `shadowColor/shadowOffset/shadowRadius/shadowOpacity` + `elevation`
- 单层 border → `borderWidth` + `borderColor`
**不可表达(必须切图)**:
- 布尔运算(subtract/intersect/exclude)
- 多层 vector 叠加(icon 组合)
- 任何 mask 组合(RN 无 mask;MaskedView 是三方库,不默认引入)
- 渐变(除非项目已接 LinearGradient,否则按退化表)
- 特殊纹理 / 光效 / 复杂 path
## 反例 (agent 常见错法)
```tsx
// Figma: BOOLEAN_OPERATION SUBTRACT(圆环)
// ❌ 试图用两层 View + borderRadius 近似圆环(内圈颜色永远对不准)
<View style={styles.ringOuter}><View style={styles.ringInner} /></View>
// ❌ 引入 react-native-svg 内联 path(未经项目确认引依赖)
```
## 违反后果
- **产物表现**: 复合几何被 View 近似,视觉失真;或私自引入三方依赖
## Rule-Scan 识别提示
- cache 出现 `BOOLEAN_OPERATION` → 强命中;多个 `VECTOR` 叠加 → 命中
- 圆/椭圆/圆角矩形不命中
- 输出 context 里列出复合几何的形态描述
## 相关
- rules/R02-fills-image.md
- rules/R03-implicit-image.md
# R12 - flat-mode-naming(RN 改写:className 冲突 → StyleSheet key 冲突)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: config 无 merge.mode 或 ≠ 'flat' → 直接放行(安全降级)
## 触发条件
- **config**: `merge.mode === 'flat'`(所有 block 产物合并到同一 styles 命名空间)
## 期望产物
- 同一 styleKey 在全部 styles 文件的 `StyleSheet.create` 顶层**只定义一次**——JS 对象合并后键覆盖前键,危害与 CSS 同名类覆盖一致
- 跨 block 的 key 带 block 前缀区分:`topbarTitle` / `cardTitle`,而非两个 `title`
## 反例 (agent 常见错法)
```ts
// blocks/topbar/styles.ts
const styles = StyleSheet.create({ title: { fontSize: rpx(18) } });
// blocks/card/styles.ts
const styles = StyleSheet.create({ title: { fontSize: rpx(14) } });
// ❌ flat 合并后一个 title 覆盖另一个,topbar 标题变 14
```
## 落地代码模板
```ts
const styles = StyleSheet.create({
topbarTitle: { fontSize: rpx(18) },
cardTitle: { fontSize: rpx(14) },
});
```
## 违反后果
- **产物表现**: 合并后一半元素样式被另一半静默覆盖
## 相关
- rules/R15-同构 map 渲染.md(同构合并可减少 key 数量)
# R13 - unit-scale(RN 文档改写,软防线:rpx 漏包语义)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌ (数值精度已由 R19/R20/R23 硬对账,本条管"包装方式")
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**: `unit.responsive.enabled === false` → 不用 rpx,退回纯数字 DP,本条只判换算
## 触发条件
- **config**: `unit.responsive.enabled === true`(rn 模板默认)
- **命中信号**: 尺寸类白名单属性(SKILL §4.1.1 §C.1)出现**裸数字**而未包 `rpx()`,或 rpx 参数不是 Figma 原值 × unit.scale
## 期望产物
**口径**(rn 模板 config:`figmaBase=375, outputBase=375, scale=1, responsive.enabled=true`):
- rpx 参数 = Figma 原值 × `unit.scale`(模板 scale=1,即 Figma 原值直填)
- 白名单属性(宽高/坐标/间距/字号/圆角等尺寸类)一律 `rpx(n)` 包装,helper 按 config `unit.responsive.helperImport/helperName` 引入
- 非尺寸类(zIndex/flex/opacity/fontWeight)**不包** rpx
## 反例 (agent 常见错法)
```ts
// Figma width=335,config responsive.enabled=true
// ❌ 裸数字漏包(小屏/大屏不缩放)
card: { width: 335 },
// ❌ rpx 参数私自换算(rpx 内部已按屏宽线性缩放,重复换算双倍错)
card: { width: rpx(670) },
// ❌ zIndex 包 rpx(非尺寸属性)
fixedBar: { zIndex: rpx(100) },
```
## 落地代码模板
```ts
import { rpx } from '@/utils/rpx'; // config unit.responsive.helperImport
card: {
width: rpx(335), // Figma 原值 × scale(=1)
borderRadius: rpx(8),
},
```
## 违反后果
- **产物表现**: 非 375 宽机型上尺寸不缩放,布局溢出/留白
## Rule-Scan 识别提示
- Read config 的 `unit` 段;`responsive.enabled=false` 时改判"数值 = Figma × scale"
- 扫 styles 白名单属性的裸数字,反查 cache bbox 判断是否漏包
- 数值精度错误不在本条重复列(归 R19/R20/R23)
## 相关
- SKILL.md §4.1.1 §C rpx 白名单
- rules/R19-padding.md / R20-absolute-position.md / R23-size-fidelity.md
# R14 - fixed-z-index(RN 改写)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 单个 fixed- → 不判(无层级冲突);与 R01 是"父规则-补充规则"关系
## 触发条件
- **cache**: ≥2 个 `fixed-` 前缀节点(可追溯、非 baked/hidden)
## 期望产物
- 各 fixed- 元素的 `zIndex` 存在且不全相同(层级可区分)
- 保守口径:只报「全部缺 zIndex」或「全部 zIndex 相同」;不强求具体递增序;动态值不计入统计
## 反例 (agent 常见错法)
```ts
// ❌ 全缺(层叠顺序交给 JSX 顺序,重构一挪就乱)
fixedNavbar: { position: 'absolute', top: 0 },
fixedBtn: { position: 'absolute', bottom: 0 },
// ❌ 全同(无法区分谁在上)
fixedNavbar: { position: 'absolute', top: 0, zIndex: 100 },
fixedBtn: { position: 'absolute', bottom: 0, zIndex: 100 },
```
## 落地代码模板
```ts
fixedNavbar: { position: 'absolute', top: 0, zIndex: 100 },
fixedBtn: { position: 'absolute', bottom: 0, zIndex: 101 },
```
## 违反后果
- **产物表现**: 多个贴屏元素交叠时层级不可控(RN zIndex 仅同父内生效,更需显式声明)
## 相关
- rules/R01-fixed-position.md
# R15 - 同构 map 渲染(RN 文档改写,软防线)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**: 同层 <3 个同构节点 → 不强制 map;各节点交互/结构差异明显 → 保留独立元素
## 触发条件
- **cache**: 同一父节点下 **≥3 个**结构同构的子节点(相同 type、相同 children 结构签名、相同 name 前缀如 `item-1/2/3`)
- **命中信号**: agent 展开成 3+ 份重复 JSX + styles key
## 期望产物
```tsx
const CARDS = [
{ title: '预约票', desc: '开售自动抢' },
{ title: '优惠券', desc: '限时领取' },
{ title: '会员权益', desc: '专享特惠' },
];
<View style={styles.cardList}>
{CARDS.map((card, i) => (
<View key={i} style={styles.card} data-node-id="211:90">
{/* data-node-id 挂代表项(variant a)的 id,副本由 _templateDup 豁免对账 */}
<Text style={styles.cardTitle}>{card.title}</Text>
<Text style={styles.cardDesc}>{card.desc}</Text>
</View>
))}
</View>
```
```ts
cardList: { gap: rpx(20) }, // RN 0.71+;低版本改 marginBottom
card: { padding: rpx(30), backgroundColor: '#FFFFFF', borderRadius: rpx(16) },
cardTitle: { fontSize: rpx(32), fontWeight: '500' },
cardDesc: { fontSize: rpx(24), color: '#666666' },
```
- styles 只写一份 key;个别差异用数组形态 `style={[styles.card, i === 0 && styles.cardFirst]}`
## 反例 (agent 常见错法)
- 展开 item1/item2/item3 三份重复 JSX + `item1Title`/`item2Title`/... 重复 key(还会触发 R12 隐患)
## 违反后果
- **产物表现**: 代码冗长 3 倍以上;加/删一项多处改;styles key 膨胀
## Rule-Scan 识别提示
- 找同层 ≥3 个子节点,判"同构":type 相同 + 子结构签名相同(如 `[TEXT, TEXT, VECTOR]`)+ 名字前缀相同(可选)
- 输出 context 列同构节点 nodeId 列表 + 每项可提取内容差异(title/imageUrl 等)
## 相关
- rules/R12-flat-mode-naming.md
- rules/R21-node-id-coverage.md(模板项挂代表项 id)
# R16 - no-flatten-text(RN 改写:标签集换图片家族 + adapter 感知)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: img-/bg- 前缀(含裸词)白名单免疫——它们天然就是切图载体;与 R17 配套(压平 vs 拆两面)
## 触发条件
- **cache**: GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT,且节点 name 前缀不在 img-/bg- 白名单
- **反查**: 产物 jsx 出现「图片家族标签 + data-node-id=<该节点>」——标签集 = `Image`/`ImageBackground`/`FastImage` + `config.adapter.tagMap.Image` 映射值
## 期望产物
- 禁止对含 TEXT 的容器整体切图;必须按前缀规则拆解:TEXT 出 `<Text>`、btn- 出 `<Pressable>`、img-/bg- 各归其位
- RN 侧危害更重:整体导出的图放进 `<Image>` 无法承载动态数据,业务侧完全无救(文案/价格/倒计时全部焊死)
## 反例 (agent 常见错法)
```tsx
// Figma: FRAME "card-price"(无 img-/bg- 前缀)内含 TEXT "¥299"
// ❌ 整卡切图,价格焊死在 PNG 里
<Image source={require('./assets/card-price.png')} data-node-id="211:66" />
```
## 落地代码模板
```tsx
<View style={styles.cardPrice} data-node-id="211:66">
<Text style={styles.cardPriceValue} data-node-id="211:67">¥299</Text>
</View>
```
## 违反后果
- **产物表现**: 文字不可改、不可本地化、无障碍缺失、按钮不可点、动态数据无处挂
## 相关
- SKILL.md §4.3「含 TEXT 容器 压平 vs 拆」唯一裁决树
- rules/R17-no-baked-dom.md(白名单内的另一面:烤进 PNG 后禁 DOM)
# R17 - no-baked-dom(RN 侧逐字节复用 h5 判定)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅(脚本与 h5 母本逐字节一致,纯 data-node-id 判定,零样式耦合)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 与 R16 配套(压平 vs 拆两面);R02/R06 跳过 `_inBakedSubtree` 节点,禁 DOM 由本条正向兜底
## 触发条件
- **cache**: 节点 `_inBakedSubtree`(祖先是 `bg-`/`img-`/`x-`,像素已烤进父层 PNG 或被忽略)
- **反查**: 产物出现其 `data-node-id` → 双重渲染
## 期望产物
- baked 子孙**不生成任何组件**——文字/图形已在父层 PNG 里,再出 `<Text>`/`<View>` 就是双重渲染
- RN 场景注意(v0.3.12 事故形态):`sub-<X> > bg-<Y> > <中间容器> > <TEXT 叶子>`——中间层遍历时同样禁止提取 TEXT 叶子出 DOM,红线扩展到中间层
## 反例 (agent 常见错法)
```tsx
// Figma: bg-header(整体切图)内含 TEXT "限时抢购"
<Image source={require('./assets/bg-header.png')} data-node-id="211:10" />
// ❌ 文字已烤进 PNG,又出了一份 Text(视觉重影)
<Text data-node-id="211:12">限时抢购</Text>
```
## 违反后果
- **产物表现**: 文字/图形重影;改文案只改了 DOM 层,PNG 里的旧文案仍在
## 相关
- rules/R16-no-flatten-text.md
- bin/lib/loadCache.mjs(`_inBakedSubtree` 标注,与 h5 同步副本)
# R18 - flex-direction(RN 语义变更:判定与 h5 镜像)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 与 R19 成对(autolayout 容器忠实度);无 styleKey 交 R21;动态方向值保守跳过
## 触发条件
- **cache**: autolayout 容器(`layoutMode === 'HORIZONTAL' | 'VERTICAL'`)
- **前置**: 该节点有 style 绑定(RN 全员 flex,无 `display: flex` 门槛——与 h5 的差异点)
## 期望产物(与 h5 镜像——RN flex 默认 column,web 默认 row)
| Figma layoutMode | RN 合法写法 | 违规 |
|---|---|---|
| `VERTICAL` | `flexDirection` **省略** 或 `'column'` | 写 `'row'`/`'row-reverse'` |
| `HORIZONTAL` | **必须显式** `flexDirection: 'row'`(或 `'row-reverse'`) | 缺失(默认 column 会把横排竖排)或写 `'column'` |
直接复制 h5 判定逻辑会全量误判——h5 拦"VERTICAL 漏写 column",RN 拦"HORIZONTAL 漏写 row",方向相反。
## 反例 (agent 常见错法)
```ts
// Figma: layoutMode=HORIZONTAL(三个标签横排)
// ❌ web 习惯:不写方向(RN 默认 column,标签竖着排下来)
tagRow: { alignItems: 'center' },
// Figma: layoutMode=VERTICAL
// ❌ 方向写反
list: { flexDirection: 'row' },
```
## 落地代码模板
```ts
tagRow: {
flexDirection: 'row', // HORIZONTAL 必须显式
alignItems: 'center',
gap: rpx(8),
},
list: {
// VERTICAL:flexDirection 省略即 column
gap: rpx(12),
},
```
## 违反后果
- **产物表现**: 横排内容竖排(或反之),整块布局崩坏——RN 侧最高频的方向事故
## 相关
- rules/R19-padding.md(成对)
- rules/RN02-flow-child-position.md(顺流子位置由父 flex 负责)
# R19 - padding(RN 改写:camelCase + rpx 剥壳对账)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 与 R18 成对;凭空捏造分支与 RN02 分工(RN02 判顺流子 cache 无却写了;本条判数值精度);无 styleKey 交 R21
## 触发条件
- **cache**: autolayout 容器声明了 padding(paddingTop/Right/Bottom/Left 任一非 0),或产物写了 padding
## 期望产物
- `paddingTop/Right/Bottom/Left` ≈ Figma 同名字段 × `unit.scale`,容差 2(剥壳后同域数值)
- 合成口径(RN 语义):具体边 > 轴简写(`paddingHorizontal`/`paddingVertical`)> 全简写(`padding`),后声明覆盖先声明
- `rpx(x)` 剥壳取 x 参与对账;任一 padding 属性动态值(Platform.select/三元)→ 整节点保守跳过
## 反例 (agent 常见错法)
```ts
// Figma: paddingTop=16, paddingLeft=20, scale=1
// ❌ 凭空捏造(Figma 没有 paddingBottom)
card: { paddingTop: rpx(16), paddingLeft: rpx(20), paddingBottom: rpx(12) },
// ❌ 数值错(16 写成 20)
card: { paddingTop: rpx(20), paddingLeft: rpx(20) },
// ❌ 漏写(内容顶到边)
card: {},
```
## 落地代码模板
```ts
card: {
paddingTop: rpx(16),
paddingLeft: rpx(20),
paddingRight: rpx(20), // Figma paddingRight=20
},
```
## 违反后果
- **产物表现**: 间距凭空出现/丢失/数值漂移,逐块累积成整页错位
## 相关
- rules/R18-flex-direction.md(成对)
- rules/R13-unit-scale.md(rpx 包装方式)
- rules/RN02-flow-child-position.md(顺流子凭空 padding)
# R20 - absolute-position(RN 改写:camelCase 数值 + 删 inset 分支)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 排斥 fixed-(那走 R01/贴屏);只管非 fixed 的 `layoutPositioning: 'ABSOLUTE'`;无 styleKey 交 R21
## 触发条件
- **cache**: `layoutPositioning === 'ABSOLUTE'` 且 name 不以 `fixed-` 开头
## 期望产物
1. style **必须声明** `position: 'absolute'`(只对数值不对声明,`position: 'relative'` 也能混过——h5 v1.2.4 同款增强)
2. `top` ≈ (子 bbox.y − 父 bbox.y) × `unit.scale`,`left` ≈ (子 bbox.x − 父 bbox.x) × scale,容差 4(剥壳后同域)
3. RN 无 `inset` 简写,该分支不存在;`rpx(x)` 剥壳取 x 对账;动态值保守跳过
## 反例 (agent 常见错法)
```ts
// Figma: 子 bbox=(120, 340),父 bbox=(20, 300),scale=1 → top=40, left=100
// ❌ 坐标靠猜
badge: { position: 'absolute', top: rpx(30), left: rpx(90) },
// ❌ 缺 position 声明(数值全对也白搭,元素还在文档流里)
badge: { top: rpx(40), left: rpx(100) },
```
## 落地代码模板
```ts
badge: {
position: 'absolute',
top: rpx(40), // (340-300)×1
left: rpx(100), // (120-20)×1
},
```
## 违反后果
- **产物表现**: 角标/徽章漂移或掉回文档流,把兄弟内容挤开
## 相关
- rules/R01-fixed-position.md(fixed- 归属)
- rules/RN02-flow-child-position.md(顺流子反面:禁写 top/left)
# R21 - node-id-coverage(RN 侧逐字节复用 h5 判定;最高优先级)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅(脚本与 h5 母本逐字节一致,正则与 cache 判定零样式耦合)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (优先级最高)
- **排斥条件**: 排斥 baked/hidden/templateDup 与 bg-/bgc-/x-(不生成独立组件);节点无 id 则 R06/R18/R19/R20 全绑定不上,先补 id 再谈其余
## 触发条件
- **正向**: 应渲染节点(TEXT / autolayout 容器 / ABSOLUTE / img-·btn-·input-)在产物 JSX 里找不到 `data-node-id`
- **反向**(v1.2.5 对齐): 产物 `data-node-id` 不存在于 cache → 幻觉 id,直接 violation
## 期望产物
- 凡承载 Figma 语义的组件必挂 `data-node-id="<figma nodeId>"`(RN 运行时忽略未知 prop;上线前 `pp-strip-nodeid` 统一剥离并转存锚点)
- `.map()` 模板项挂**代表项(variant a)**的 id;唯一例外是 Figma 里不存在源节点的虚拟 wrapper(如 `end-` 机制的 `__front-group`、骨架的 root/scroll/scrollContent)
## 反例 (agent 常见错法)
```tsx
// ❌ 正向漏挂(该 Text 逃出全部对账)
<Text style={styles.title}>限时抢购</Text>
// ❌ 反向幻觉(cache 里没有 9:99 —— test29 形态:截断 cache + 幻觉 id 真空通过)
<View style={styles.card} data-node-id="9:99" />
```
## 落地代码模板
```tsx
<Text style={styles.title} data-node-id="211:12">限时抢购</Text>
```
## 违反后果
- **产物表现**: 漏挂 = 该节点逃出 R06/R18/R19/R20/R23 全部对账,bug 静默逃逸;幻觉 id = 对账对到不存在的节点,防线真空通过
## 相关
- SKILL.md §5.1.1 data-node-id 全覆盖铁律
- rules/R23-size-fidelity.md(1×1 锚点欺诈——为混过本条而生的对策)
# R22 - empty-visual-btn(RN 改写,warning 级不阻断)
## 判定归属
- **硬防线**: warning 级(进 warnings,不 exit 1)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: baked/hidden/templateDup/无 styleKey 跳过(不可追溯交 R21)
## 触发条件
- **cache**: btn- 节点 bbox 面积 > 0、可追溯,但产物无任何可见视觉:
- 子树无可见 TEXT(cache 侧)
- 子树全部 styleKey 的规则体无 `backgroundColor` / `borderWidth` / `borderColor`
- jsx 无图片家族标签(Image/ImageBackground/FastImage + tagMap.Image)挂子树任一 id
- 所在文件无 `<LinearGradient`
## 期望产物
- btn- 应有可见视觉(文字/背景/边框/图片/渐变);纯透明热区在少数设计合法(叠在 bg- 整图上),故仅 warning,主 agent 必须在 QA 段复核
## 反例 (agent 常见错法)
```ts
// ❌ 按钮退化成透明热区(典型根因: cache 深度截断把内容丢了 / 该切图没切)
btnQiang: { width: rpx(120), height: rpx(44) },
```
## 违反后果
- **产物表现**: 按钮"隐形",可点但看不见——多数情况是内容在数据侧被截断丢失的下游症状
## 相关
- 门禁 GATE-cache-truncation(上游根因拦截)
- rules/R21-node-id-coverage.md
# R23 - size-fidelity(RN 改写:覆盖面 ≥ h5)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 无 styleKey 交 R21;动态值 unparseable 保守跳过
## 触发条件
- **产物**: 应渲染节点的 style 声明了显式数值 `width`/`height`(纯数字或 rpx 包装)
## 期望产物
- `width`/`height`(rpx 剥壳后)≈ `absoluteBoundingBox` × `unit.scale`,容差 4
- **锚点欺诈点名**(test28 形态):`width: 1, height: 1` + `overflow: 'hidden'` 且真实尺寸 > 8 → 直接 violation——为混过 R21 的 id 覆盖检查而把真实元素缩成隐藏点
- **RN 恒为 border-box**:h5 的 `hasPadding && !hasBorderBox` 跳过分支不存在,含 padding 节点照判,覆盖面比 h5 更大
## 反例 (agent 常见错法)
```ts
// Figma bbox = 331.5 × 141,scale=1
// ❌ 锚点欺诈(挂着 id 的 1×1 隐藏点应付 R21,真实 UI 用别的无 id 元素渲染)
cardAnchor: { width: 1, height: 1, overflow: 'hidden' },
// ❌ 尺寸漂移(超容差)
card: { width: rpx(320), height: rpx(130) },
```
## 落地代码模板
```ts
card: { width: rpx(331.5), height: rpx(141) },
```
## 违反后果
- **产物表现**: 元素尺寸失真;锚点欺诈则是整套对账被"应付检查"架空
## 相关
- rules/R21-node-id-coverage.md(欺诈动机来源)
- rules/R13-unit-scale.md(rpx 口径)
# pp-d2c-rn 规则库
> pp-d2c-rn skill 硬性规则的原始定义。当 rules/*.md 内容与 SKILL.md 冲突时以 rules/ 为准。
> 与 h5 pp-d2c v1.2.5 规则库同代:R 系编号语义对齐(输出层换 RN),RN01-RN04 为 RN 特有规则(独立命名空间,与 h5 未来的 R24+ 隔离)。
## 内置前缀常量表(硬编码,不可配置)
与 h5 完全一致——前缀语义端无关,设计师-开发者-脚本三方共享同一份协议:
| 前缀 | 语义(RN 落地) |
|---|---|
| `sub-` | 分块边界(sub-agent 派发单元) |
| `block-` | 独立布局块(styleKey 命名空间隔离) |
| `img-` | 图片内容(生成 `<Image>`,不递归) |
| `bg-` | 背景图(独立 `<Image>` 挂父容器内头部 + absolute + Figma 事实尺寸,子孙不生成组件) |
| `bgc-` | 背景纯色(borderColor/borderWidth/borderRadius/shadow* 写父 style,自身不生成组件) |
| `btn-` | 可点击区域(`<Pressable>`,永远 style 化) |
| `scrollx-` | 横向滚动容器(`<ScrollView horizontal>`) |
| `scrolly-` | 纵向滚动容器(`<ScrollView>`) |
| `fixed-` | 贴屏定位(修饰前缀;一律放根 View 直接子层,absolute + zIndex≥100) |
| `end-` | 逆向布局(贴父末端,修饰前缀) |
| `input-` | 输入框(生成 `<TextInput>`,不递归) |
| `x-` | 忽略(跳过整层,优先级最高) |
## 索引表
| ID | 名称 | 判定归属 | 一句话触发条件(RN 口径) |
|---|---|---|---|
| R01 | fixed-position | 硬防线 | `name.startsWith('fixed-')` → absolute + zIndex≥100 + 根 View 直接子层 |
| R02 | fills-image | 硬防线 | fills 含可见 IMAGE → 切图记录 + 产物引用(Image/require/uri) |
| R03 | implicit-image | 硬防线 | 无前缀 + 子树 ≥3 真矢量路径 + 无 TEXT/交互 → 该整体切图 |
| R04 | text-gradient | 硬防线(语义变更) | TEXT 末位可见 GRADIENT_*/IMAGE → 退化首 stop 纯色 + assets.txt [退化告警] 行 |
| R05 | space-between | 硬防线 | `primaryAxisAlignItems === 'SPACE_BETWEEN'` → `justifyContent: 'space-between'` |
| R06 | text-solid-last | 硬防线 | TEXT 末位可见 SOLID → `color: '#hex'` 取末位色值 |
| R07 | multi-fills | 软防线 | fills ≥2 层可见混合 → 拆层落地(底 View backgroundColor + 上层 Image/LinearGradient) |
| R08 | bg-landing-form | 硬防线(语义变更) | bg- 节点 → 独立 Image + absolute + 数值固定尺寸 |
| R09 | btn-bgc-取值 | 硬防线(语义变更) | btn- 内 bgc- 末位 GRADIENT → LinearGradient 或 首 stop 纯色+退化留痕 |
| R10 | no-fake-solid-color | 软防线 | 产物色值在 cache 找不到源头(幻觉色) |
| R11 | mask-vector-css-able | 软防线 | 复合 mask / 多层 vector → RN 可表达集比 CSS 更小,基本一律切图 |
| R12 | flat-mode-naming | 硬防线 | `merge.mode === 'flat'` 下 StyleSheet key 跨文件重复定义 ≥2 |
| R13 | unit-scale | 软防线 | rpx 白名单属性漏包 / 数值未按口径换算 |
| R14 | fixed-z-index | 硬防线 | ≥2 个 fixed- 节点 zIndex 全缺或全同 |
| R15 | 同构 map 渲染 | 软防线 | 同层 ≥3 同构子节点须 `.map()` 模板渲染 |
| R16 | no-flatten-text | 硬防线 | 含 TEXT 容器(前缀非 img-/bg-)整体切成图片家族标签 |
| R17 | no-baked-dom | 硬防线 | `_inBakedSubtree` 节点在产物出现 data-node-id(双重渲染) |
| R18 | flex-direction | 硬防线(语义变更) | **判定与 h5 镜像**:RN 默认 column——VERTICAL 省略合法;HORIZONTAL 必须显式 `'row'` |
| R19 | padding | 硬防线 | `paddingT/R/B/L` ≈ Figma × unit.scale(rpx 剥壳,容差 2) |
| R20 | absolute-position | 硬防线 | ABSOLUTE 节点须声明 `position: 'absolute'` 且 top/left ≈ (子bbox−父bbox)×scale(容差 4) |
| R21 | node-id-coverage | 硬防线 | 应渲染节点漏挂 data-node-id;反向:产物 id ∉ cache = 幻觉 id |
| R22 | empty-visual-btn | warning | btn- 无文字/背景/边框/图片/渐变 → 空视觉按钮嫌疑 |
| R23 | size-fidelity | 硬防线 | 显式数值宽高 ≈ bbox×scale(容差 4);1×1+overflow:hidden 锚点欺诈点名(RN 恒 border-box,无盒模型跳过分支,覆盖面 ≥ h5) |
| RN01 | scroll-skeleton | 硬防线(RN 特有) | 页面根强制 View>ScrollView>View(scrollContent) 骨架;block 反向禁套 |
| RN02 | flow-child-position | 硬防线(RN 特有) | 顺流子禁 position/top/left/right/bottom/margin*;padding 须溯源;flex:1 须 FILL 依据 |
| RN03 | no-percent-fill | 硬防线(RN 特有) | bg- 铺满层禁 '100%' 宽高与 absoluteFillObject(父 minHeight 塌陷) |
| RN04 | styles-file-separation | 硬防线(RN 特有) | jsx 内禁 StyleSheet.create 与静态 inline style 对象 |
## 判定归属说明
**硬防线 21 条 exit-1**(`check-rules.mjs` 自动拦截):R01-R06 / R08 / R09 / R12 / R14 / R16-R21 / R23 + RN01-RN04。
**软防线 5 条**(Rule-Scan sub-agent 识别,输出 `rule-hits.json`):R07 / R10 / R11 / R13 / R15——判定逻辑需 LLM 语义能力,文档即唯一定义。
**warning 级**(提示不阻断):R22 empty-visual-btn;R01 的 ScrollView 区间判不了时的降级提示;R05 的 margin auto 模拟提示。
**四道流程门禁**(与 h5 同代,零样式耦合直接复用):**GATE-cache-truncation**——空 GROUP/BOOLEAN_OPERATION = fetch depth 截断实锤;**GATE-rule-hits**——rule-hits.json 缺失即 exit 1,fallback 占位须伴随 assets.txt `[Rule-Scan 降级]` 记录,捏造消费证明点名;**IMG-reconcile**(--merge)——产物图片引用必须来自 slice-manifest(RN 五种引用形态 require / source={{uri}} / ImageBackground / FastImage / `${ASSET_PREFIX}` 的文件名均被正则捕获);**GATE-slice-confirm**(--merge)——manifest `confirmed` 须为 true。
**对账基座**:R02/R06/R17/R18/R19/R20 依赖 `bin/lib/loadCache.mjs` 三标注(`_inBakedSubtree`/`_hidden`/`_templateDup`,与 h5 逐字节同步副本),样式匹配走 `bin/lib/styleMatch.mjs`(RN StyleSheet 轻量词法解析,`getNumeric` 统一 rpx 剥壳)。数值对账口径:**期望 = Figma 原值 × unit.scale,产物 rpx(x) 剥壳后的 x 同域直接比**(rn 模板 config `unit.scale=1`、`responsive.enabled=true`)。config 缺 `unit` 段时 check-rules 直接 exit 2——rpx 口径下兜底默认 scale 会全量误判,rn 侧强制显式声明。
## 使用方式
### Rule-Scan sub-agent
**派发时机**:每个 `sub-` block 出码前各派一次;页面无 sub- 时对整页派一次(页面根视为虚拟 block,`rule-hits.json` 落页面根目录)。
派发时的完整 prompt:
```
你是 Rule-Scan sub-agent, 只做规则识别, 不写 UI 代码.
任务:
1. Read templates/skills/pp-d2c-rn/rules/*.md 全部规则(全量扫描)
2. Read .d2c-cache/<cache-key>/nodes/ 下与本 block nodeIds 相关的 JSON
3. 对本 block 的每个节点, 判断命中了哪些规则
4. 输出 rule-hits.json (schema 见附)
规则命中判定原则:
- 硬防线规则 (R01-R06/R08/R09/R12/R14/R16-R21/R23/RN01-RN04) 与 warning 级 R22:
必须扫出命中作为生成前逐节点指引(判决权在 check-rules.mjs,指引漏扫不算违规,但禁止整类跳过)
- 软防线规则 (R07/R10/R11/R13/R15): 你是唯一识别方
- 排斥条件: 若节点命中高优先级规则, 低优先级规则不再重复列
- 优先级 (由高到低): R21 > RN04 > RN01 > R16 > R17 > RN02 > R02 > R01 > RN03 > R05 > R11 >
R03 > R04 > R07 > R06 > R09 > R08 > R20 > R18 > R19 > R14 > R15 > R13 > R12 > R10
(R21 最高:节点不可追溯则其余绑定类规则无从谈起;RN04/RN01 次之:样式混写/骨架缺失是结构性坍塌,
其余规则的对账在错误结构上无意义)
输出要求:
- 每个 hit 包含 nodeId / rule / trigger 描述 / expected 描述 / context (关键 JSON 字段抽样)
- 输出 JSON, 不带 markdown 代码块围栏, 不加解释文字
- 落盘到 blocks/{sub}/rule-hits.json
禁止:
- 不允许写 JSX / styles
- 不允许改 cache 文件
- 不允许基于"设计意图猜测"命中规则; 只按 rules/*.md "触发条件" 字面判定
```
### UI sub-agent
- Read `blocks/{sub}/rule-hits.json` 里涉及的规则 .md,按"期望产物"落地
- 生完 JSX + styles.ts 后跑:
```bash
node .claude/skills/pp-d2c-rn/bin/check-rules.mjs \
--block blocks/{sub}/ \
--cache-key <fileKey>
```
- exit 0 继续 / exit 1 按 violations 回滚重做 / exit 2 报环境错
- `assets.txt` 追加"rule-hits 消费证明"块(格式同 h5);渐变退化(R04/R09)必须在 assets.txt
追加 `[退化告警] <nodeId> <图层名>: GRADIENT_* 退化为首 stop <色值>` 行——缺行即 violation
### check-rules.mjs
- 硬编码全部 21+1 条规则逻辑与四道门禁,rules/*.md 是设计文档,不是执行文档
- 假阳性时用 `--force-skip R0X,R0Y` 跳过,但 UI sub-agent 必须在 `assets.txt` 备注 `[脚本误判] R0X {nodeId} 理由: ...`(三段证据:文件:行号 + grep 命令 + 命中内容;单次 ≤3 条);生成流程禁用 `--force-skip`
- 详细 CLI 见 `templates/skills/pp-d2c-rn/bin/check-rules.mjs --help`
## 排斥关系图(RN 侧增补)
```
h5 既有排斥关系全部保留(R02→R11/R09、R06→R04/R10、R16↔R17、R18—R19 成对、R20 排斥 fixed-、R21 最高),另:
R01 (fixed-position) ── RN01 (scroll-skeleton): fixed- 不入 ScrollView 的逐节点区间判定归 R01;
RN01 只管骨架本体(存在性/minHeight/overflow),不重复报 fixed- 位置
R08 (bg-landing-form) ── RN03 (no-percent-fill): 正向契约(Image+absolute+数值尺寸)归 R08;
'100%'/absoluteFillObject 塌陷写法归 RN03,不双报
RN02 (flow-child-position) ── R19 (padding): RN02 只判"cache 无 padding 却写了"的凭空捏造分支;
数值精度对账归 R19
RN02 ── R20: 子 layoutPositioning=ABSOLUTE 的归 R20,RN02 只管顺流子
RN04 (styles-file-separation) ── 全部数值规则: 样式混写进 jsx 会让 styleMatch 失明,
RN04 先行拦截,数值规则不对 inline 样式负责
```
## 版本
- **v1.0.0** 防线代与 h5 v1.2.5 对齐:R01-R23 全量移植(R01/R04/R08/R09/R18/R23 语义变更,详见各文档)+ RN01-RN04(v0.3.12/0.3.13 文本约束升格机械判定)+ 四道门禁 + styleMatch 对账基座(cssMatch 的 RN 重写)
- v0.3.x 时代:规则散落在 SKILL.md 章节,靠文本约束 + grep 自证,无机械防线
## 相关
- `templates/skills/pp-d2c-rn/SKILL.md` — 主流程
- `templates/skills/pp-d2c-rn/bin/check-rules.mjs` — 硬防线脚本
- `templates/skills/pp-d2c/rules/` — h5 母本规则库(R 系编号语义对齐)
- `.Knowledge/req-docs/pp-d2c-rn-防线移植_技术方案.md` — 本轮技术方案
# RN01 - scroll-skeleton(RN 特有:页面根强制骨架)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅(--merge 正向 / --block 反向;骨架 key 缺失时细则降 warning)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: fixed- 不入 ScrollView 的逐节点区间判定归 R01,本条只管骨架本体
## 触发条件与校验
**--merge(页面级)**:
1. 页面 jsx 必须出现滚动容器标签(`ScrollView` + tagMap.ScrollView)——rn 分支**不判视口**,所有页面一律套骨架:`View(root) > ScrollView > View(scrollContent)`
2. styles 的 `scrollContent`:必须用 `minHeight`,写死 `height` 即违规(内容超高被裁,用户看不到底部——v1.0.3 事故形态:1579px 长图 + 死高 View,RN 的 View 天然不滚)
3. `root` / `scrollContent` 规则体禁 `overflow: 'hidden'`(阻止滚动)
**--block(反向)**:
- block 产物**不得**套页面骨架——同时存在滚动容器标签与 `scrollContent` key 即违规(骨架只属于页面根,由主 agent 合并时套);`scrollx-`/`scrolly-` 的普通 ScrollView 不受影响(其 styleKey 不叫 scrollContent)
## 期望产物(SKILL §4.1.1 固定骨架)
```tsx
<View style={styles.root}> {/* flex:1 + position:'relative',承接 fixed-* */}
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<View style={styles.scrollContent}> {/* width + minHeight + paddingTop */}
{/* bg-body Image + 顶层 frame 顺流子 + bottomPadding */}
</View>
</ScrollView>
{/* fixed-* 放这里,根 View 直接子层 */}
</View>
```
```ts
root: { flex: 1, position: 'relative' },
scroll: { flex: 1 },
scrollContent: {
width: rpx(375),
minHeight: rpx(1579), // 不用 height:内容不足时至少这么高,超出自动增高
},
```
## 反例 (四种硬错)
```ts
// ❌ 1: 根 View 直接装内容不套 ScrollView(内容被裁,不滚)
// ❌ 2: scrollContent 写死 height: rpx(1579)
// ❌ 3: root 或 scrollContent 写 overflow: 'hidden'
// ❌ 4: block 产物套了骨架(scrollContent key + ScrollView 同现)
```
## 违反后果
- **产物表现**: 长页面底部内容永远不可见;或 block 合并后双层 ScrollView 滚动冲突
## 相关
- SKILL.md §4.1.1 rn 页面根强制骨架 + fixed-* 分层
- rules/R01-fixed-position.md(fixed- 区间判定)
- rules/RN03-no-percent-fill.md(骨架内 bg- 铺满层)
# RN02 - flow-child-position(RN 特有:顺流子位置来源硬约束)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 子 `layoutPositioning === 'ABSOLUTE'` → 归 R20;fixed- 前缀子 → 归 R01;bg- 前缀子(含裸词 bg)→ 归 R08/RN03(铺满层契约本身要求 absolute);子自身是 autolayout 容器时 padding 分支让位 R19(R19 对 autolayout 容器做含凭空分支的全量对账);显式 0 / rpx(0) 的 margin/offset 不改变布局,保守放行(position 无数值形态,恒报);无 styleKey 交 R21
## 触发条件
- **cache**: 父 `layoutMode ∈ {HORIZONTAL, VERTICAL}` 且子 `layoutPositioning !== 'ABSOLUTE'`(顺流子)
## 校验(位置由父 flex 5 字段负责:flexDirection/justifyContent/alignItems/gap/padding)
1. 子 style **禁**出现 `position` / `top` / `left` / `right` / `bottom` / `margin*`(含 Horizontal/Vertical)——用 `absoluteBoundingBox` 逆推 margin/absolute 会绕过父 flex 语义
2. 子 style 的 `padding*` 非 0 值必须溯源到该子节点 cache 同名字段(`padding`/`paddingHorizontal`/`paddingVertical` 简写按展开边溯源)
3. 子 style 的 `flex: 1` 仅当 cache `layoutGrow === 1` 或 `layoutSizingHorizontal/Vertical === 'FILL'`(FIXED sizing 的子尺寸写事实值)
## 反例(v0.3.13 真实事故形态)
```ts
// Figma: 父 FRAME VERTICAL / primaryAxisAlignItems=CENTER / itemSpacing=N
// 三个顺流子被 agent 用 absoluteBoundingBox 逆推:
// ❌ 分别写 marginTop:<y1> / marginTop:<y2> / position:'absolute', top:<y3>
// → 绕过父 flex 语义,视觉整体下移
item1: { marginTop: rpx(120) },
item2: { marginTop: rpx(184) },
item3: { position: 'absolute', top: rpx(248) },
// ❌ 同批命中: paddingLeft 凭空捏造(cache 无该字段)、flex:1 违反 FIXED sizing
item4: { paddingLeft: rpx(12), flex: 1 },
```
## 落地代码模板
```ts
// 位置全部由父承担
parent: {
// VERTICAL: flexDirection 省略
justifyContent: 'center',
gap: rpx(16), // itemSpacing
},
item1: { width: rpx(200), height: rpx(48) }, // 子只写自身尺寸与视觉
```
## 违反后果
- **产物表现**: 顺流内容整体漂移/重叠;改一处父布局,所有逆推值全部失效
## 相关
- SKILL.md §4.3 顺流子位置来源硬约束(v0.3.13)
- rules/R18-flex-direction.md / R19-padding.md / R20-absolute-position.md
# RN03 - no-percent-fill(RN 特有:%-塌陷防御)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 正向契约(独立 Image + absolute + 数值尺寸)归 R08,本条只拦塌陷写法;无 styleKey 交 R21
## 触发条件
- **cache**: `bg-` 前缀节点(含裸词 `bg`)
## 校验
该节点 style 禁止:
1. `width` / `height` 为百分比字符串(如 `'100%'`)
2. 引用 `StyleSheet.absoluteFillObject`(等价于全 % 铺满)
**原因**:父容器用 `minHeight` 时,`%` 值引用父的**计算高度**(可能小于 Figma 设计稿高度),背景层跟着塌陷。必须写 Figma 事实固定尺寸 + 精确定位。
## 反例(v0.3.12 真实事故形态)
```ts
// ❌ 塌陷写法 1
bgBody: { ...StyleSheet.absoluteFillObject },
// ❌ 塌陷写法 2
bgBody: { position: 'absolute', width: '100%', height: '100%' },
```
## 落地代码模板
```ts
bgBody: {
position: 'absolute',
top: 0,
left: 0,
width: rpx(375), // Figma 事实值
height: rpx(1579), // Figma 事实值
},
```
## 违反后果
- **产物表现**: 背景图高度随父计算高度缩水,页面下半段露底色
## 相关
- SKILL.md §4.1.1 bg- 铺满层用 Figma 事实尺寸
- rules/R08-bg-landing-form.md(正向契约)
- rules/RN01-scroll-skeleton.md(scrollContent minHeight 是塌陷诱因)
# RN04 - styles-file-separation(RN 特有:styles.ts 强制独立文件)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 无——本条是全部数值规则的前置(样式混进 jsx 会让 styleMatch 引擎失明,先行拦截)
## 触发条件
- **产物**: 任一 jsx 文件(.jsx/.tsx)
## 校验
1. jsx 文件内禁出现 `StyleSheet.create`——样式必须在独立 styles 文件(`styles.ts` / `styles.js` / `*.styles.ts`),否则响应式改写 / adapter 改写会触碰 JSX
2. jsx 内禁静态 inline style 对象 `style={{...}}`——逃出 styleMatch 对账,R18/R19/R20/R23 全部失明
3. **放行**:`style={[styles.a, 动态变量]}` 数组形态(动态成员不参与机械对账,由 nodeIdToStyleKey 忽略)
## 反例(v0.3.12 收紧的三种混写)
```tsx
// ❌ 1: StyleSheet.create 写在 index.tsx 底部
const styles = StyleSheet.create({ card: {...} });
// ❌ 2: 静态 inline style
<View style={{ width: 335, padding: 16 }} />
// ❌ 3: const styles = {...} 裸对象内联(同属混写)
```
## 落地代码模板
```
blocks/card/
├── index.tsx ← 只有 JSX,import { styles } from './styles'
└── styles.ts ← 全部 StyleSheet.create
```
```tsx
// index.tsx
import { styles } from './styles';
<View style={styles.card} data-node-id="211:40" />
<View style={[styles.card, isActive && styles.cardActive]} /> // 数组 + 条件成员放行
```
## 违反后果
- **产物表现**: 引擎对该节点全部数值对账失明;adapter 阶段标签替换与样式改写互相踩踏
## 相关
- SKILL.md §5 合并结构(v0.3.12 强制独立文件)
- bin/lib/loadProduct.mjs(styles 文件双条件识别)
+2
-2
{
"name": "@double-coding/pixel-print",
"version": "1.4.0",
"version": "1.5.0-beta.0",
"description": "PixelPrint(像素打印)—— Figma D2C 工具,一键安装 Claude Code Skill,像素级还原设计稿为前端代码(H5 / React Native / xtaro)",

@@ -10,3 +10,3 @@ "bin": {

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

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

#!/usr/bin/env node
// 同步副本:主本 templates/skills/pp-d2c/bin/figma.mjs(v1.0.0 起与 h5 同步刷新,含 confirm-slices / truncatedSuspects),上游修复须同步搬运
// figma.mjs — Figma REST API 封装脚本

@@ -223,2 +224,22 @@ // 用途:把 SKILL.md 里"每次都让 LLM 手拼 curl / 手管缓存"的机械动作固化下来。

// 深度截断嫌疑检测: 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 +261,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 +281,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 +344,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 +421,3 @@

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

@@ -384,2 +436,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 +439,0 @@ node figma.mjs cleanup-tmp

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