@double-coding/pixel-print
Advanced tools
| #!/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
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1402304
15.75%187
43.85%9927
32.25%1
Infinity%35
25%