/** * 间距与圆角校验:核对 `AGENTS.md` 的 * 「整个项目所有卡片的圆角都必须是8px,无阴影,边距间隔一致,为8px。」 * * 背景(2026-09-18 ~ 09-20 的真实教训): * 这条规则先后做过两批(4.17 圆角 / 4.18 边距),两批**都报过不实的结论**: * * ① 4.17 用「命名判定」(键名含 card)找卡片 → **漏网**。`pendingItem` / `dimItem` / * `suggestionRow` / `matchBanner` / `salaryBox` / `taxItem` 这些名字里没有 card、 * 结构上却是卡片的块,一个都没被查到。改成结构判定后才浮出来。 * ② 4.18 我报「卡片 477 / 圆角 = 8:217」,并说「剩余非 8 的只有若干控件」。 * 实际上那个脚本的排除正则是 `^(...|btn|tag|chip|...)$` —— **锚定全名**, * 于是 `retryBtn` / `statusTag` / `filterChip` / `progressTrack` 这类 * **带后缀的键一个都匹配不上**,260 个控件全被当成卡片报出来。 * 「217/477」是脚本的原始通过数,剩下 260 项是我**肉眼判成控件**的, * **脚本从未验证过这个判断**。这就是「推断冒充验证」。 * * 所以本脚本把判据**编码进去**,而不是留在人脑里: * * 卡片 = 有 borderRadius 且(有 backgroundColor 或 borderWidth),且不属于下列形态 * * ① 圆:`width === height` 且 `borderRadius ≈ width / 2`。 * 圆不是卡片 —— 把 120×120 / r60 的装饰圆改成 8 会把它变成圆角方块,属误改。 * 用**几何判据**而不是名字清单,是为了以后新增的圆自动被认出来。 * ② 控件 / 标签 / 按钮 / 图标底:按**词尾**判定(不是全名!见上文缺陷)。 * 排除项一律**逐条打印供复核**,不静默吞掉 —— 否则排除表本身会变成新的黑箱。 * * **不排除**:`option` / `row` / `card` / `preview` / `notice` / `taxItem` / `dimItem` / 任何 `*Item`。 * 它们本身就是卡片,必须参与核对。(`RadioRow.row`、`holland/question.option` 同形态, * 都是卡片;把 RadioRow 归成「控件排除」是本脚本要防的那类错误。) * * 三个维度(对应规则里的「边距间隔一致」): * 页面留白 —— 主 ScrollView 的 contentContainerStyle 的 padding / paddingHorizontal * 卡片内边距 —— 卡片的 padding(无 padding 时看 paddingHorizontal / paddingVertical) * 卡片间隔 —— 卡片的 marginTop / marginBottom * * **不在范围内**(规则主语是「所有卡片」,用户确认的「页面留白」问的是「卡片离屏幕边缘的距离」): * - 卡片**内部**子元素之间的间距(如卡内若干行文字的 gap) * - 页面框架(头部返回箭头 backRow / 底部固定区 footer)—— 主应用同类取值为 16,非 8 * - 控件自身的 padding(输入框、chip、标签) * * 可失败性:本脚本已用塞了 4 类违规(r=12 的卡 / padding 12 的卡 / marginTop 12 的卡 / * 留白 16 的页)的临时文件验证过,四个维度全部命中。**「全绿」必须配上这一步, * 否则分不清是合规还是脚本坏了。** * * 用法:node scripts/check-spacing.mjs # 列出全部 + 排除项 * node scripts/check-spacing.mjs --error # 有命中则 exit 1(可用于 CI) */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; const STRICT = process.argv.includes('--error'); const ROOTS = ['app', 'components', 'src']; const TARGET = 8; /** 要核对的间距维度 */ const CARD_GAP_PROPS = ['marginTop', 'marginBottom']; /** * 控件 / 标签 的词尾判据。 * **必须按词尾匹配**:锚定全名会漏掉 `retryBtn` / `statusTag` / `filterChip` 这类带后缀的键 * (4.18 的原始缺陷)。大小写不敏感:RN 里 `tag` / `input` / `chip` 大量是小写键。 */ const CONTROL_SUFFIX = /(Btn|Button|Tag|Chip|Badge|Input|Indicator|TextArea|Upload|Checkbox|Puzzle|Wrap|Tab|Track|Fill|Bar|Dot|Circle|Knob|Slider|Radio|Thumb|Handle|Logo|Icon|Toast|Cancel|Confirm|Pie|Line|opt|placeholder|paper|slot|block|progress|counter|glow|Submit)$/i; /** * 具名例外(卡片形态的除外项,逐个看过,不是「差不多」)。 * 加这一条要写理由 —— 名单是判据的一部分,不是把输出压到 0 的手段。 */ const EXPLICIT_CONTROL = new Set([ 'filterItem', // skill-cloud 行业筛选胶囊 'emptyTabItem', // job-evaluations 空态里的 Tab 项 'tipAction', // jobs/rank 的「查看全部」文字链 'templateLineShort', // resume/export 模板缩略图里的模拟文字行 'box', // onboarding/SearchInput、AgreementCheckbox 的控件盒 'inputBox', // salary-calculator 输入位 'searchBox', // jobs 搜索框 'logoBox', // enterprise-overview Logo 底 'avatarBox', // candidate-detail 头像位 'uploadBox', // appeal 上传位 'puzzleArea', // SliderCaptcha 拼图区 ]); /* ---------------- 解析 ---------------- */ function walk(dir, out = []) { if (!existsSync(dir)) return out; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) walk(full, out); else if (/\.tsx$/.test(entry)) out.push(full.split(/[\\/]/).join('/')); } return out; } /** 取 StyleSheet.create 里的顶层样式块(两空格缩进),单行块与多行块都要支持 */ function styleBlocks(src) { const out = []; const lines = src.split('\n'); for (let i = 0; i < lines.length; i += 1) { const head = lines[i].match(/^(\s{2})([A-Za-z_$][\w$]*)\s*:\s*\{/); if (!head) continue; const body = [lines[i]]; if (!/\}\s*,?\s*$/.test(lines[i])) { for (let j = i + 1; j < lines.length; j += 1) { if (new RegExp(`^${head[1]}\\}`).test(lines[j])) break; body.push(lines[j]); } } out.push({ key: head[2], text: body.join('\n') }); } return out; } /** * 单行样式块 `a: { gap: 12 },` 会让 `^\s*prop:` 这类行首锚点全部失效, * 所以先把 `{ } ,` 换成换行再匹配。 */ const flatten = (text) => text.replace(/[{},]/g, '\n'); function readNumber(text, prop) { const m = flatten(text).match(new RegExp(`^[ \\t]*${prop}:[ \\t]*(-?[0-9.]+)`, 'm')); return m ? Number(m[1]) : null; } const hasProp = (text, prop) => new RegExp(`^[ \\t]*${prop}:`, 'm').test(flatten(text)); const isCircle = ({ text }) => { const w = readNumber(text, 'width'); const h = readNumber(text, 'height'); const r = readNumber(text, 'borderRadius'); return w !== null && h !== null && w === h && Math.abs(r - w / 2) < 0.51; }; /* ---------------- 核对 ---------------- */ const files = ROOTS.flatMap((root) => walk(root)); const violations = { radius: [], padding: [], gap: [], gutter: [] }; const excluded = []; let cardCount = 0; let radiusOk = 0; for (const file of files) { const src = readFileSync(file, 'utf8'); if (!src.includes('StyleSheet.create(')) continue; for (const block of styleBlocks(src)) { if (readNumber(block.text, 'borderRadius') === null) continue; if (!hasProp(block.text, 'backgroundColor') && !hasProp(block.text, 'borderWidth')) continue; if (isCircle(block)) { excluded.push(`${file} | ${block.key} | 圆 ${readNumber(block.text, 'width')}`); continue; } if (CONTROL_SUFFIX.test(block.key) || EXPLICIT_CONTROL.has(block.key)) { excluded.push(`${file} | ${block.key} | 控件/标签`); continue; } cardCount += 1; const radius = readNumber(block.text, 'borderRadius'); if (radius !== TARGET) { violations.radius.push(`${file} | ${block.key} | borderRadius ${radius}`); continue; } radiusOk += 1; const padding = readNumber(block.text, 'padding'); if (padding !== null) { if (padding !== TARGET) violations.padding.push(`${file} | ${block.key} | padding ${padding}`); } else { for (const prop of ['paddingHorizontal', 'paddingVertical']) { const value = readNumber(block.text, prop); if (value !== null && value !== TARGET) { violations.padding.push(`${file} | ${block.key} | ${prop} ${value}`); } } } for (const prop of CARD_GAP_PROPS) { const value = readNumber(block.text, prop); if (value !== null && value !== TARGET) { violations.gap.push(`${file} | ${block.key} | ${prop} ${value}`); } } } } /** * 页面留白:取主内容 ScrollView。 * 一页可能有多个 ScrollView(空态 / 错误态 + 主内容),**主内容在后面**,故取最后一个匹配; * 没有 contentContainerStyle 的文件(纯 View 布局)用候选键名兜底。 */ const GUTTER_FALLBACK_KEYS = ['content', 'body', 'scrollContent', 'listContent', 'group', 'container']; for (const file of files) { const src = readFileSync(file, 'utf8'); if (!src.includes('contentContainerStyle')) continue; const referenced = [...src.matchAll(/contentContainerStyle=\{styles\.([\w$]+)/g)].map((m) => m[1]); const candidates = referenced.length ? [referenced[referenced.length - 1]] : []; for (const key of GUTTER_FALLBACK_KEYS) if (!candidates.includes(key)) candidates.push(key); const blocks = styleBlocks(src); for (const key of candidates) { const block = blocks.find((b) => b.key === key); if (!block) continue; const padding = readNumber(block.text, 'padding'); const horizontal = readNumber(block.text, 'paddingHorizontal'); if (padding === null && horizontal === null) continue; const value = padding !== null ? padding : horizontal; if (value !== TARGET) violations.gutter.push(`${file} | ${key} | ${value}`); break; } } /* ---------------- 输出 ---------------- */ const total = violations.radius.length + violations.padding.length + violations.gap.length + violations.gutter.length; console.log(`卡片(结构判定):${cardCount},圆角 = ${TARGET}:${radiusOk} / ${cardCount}`); console.log(` 圆角 ≠ ${TARGET}:${violations.radius.length}`); violations.radius.forEach((line) => console.log(` ${line}`)); console.log(` 内边距 ≠ ${TARGET}:${violations.padding.length}`); violations.padding.forEach((line) => console.log(` ${line}`)); console.log(` 卡片间隔 ≠ ${TARGET}:${violations.gap.length}`); violations.gap.forEach((line) => console.log(` ${line}`)); console.log(` 页面留白 ≠ ${TARGET}:${violations.gutter.length}`); violations.gutter.forEach((line) => console.log(` ${line}`)); console.log(`--- 已剔除 ${excluded.length} 项(圆 / 控件),逐条列出供复核 ---`); excluded.forEach((line) => console.log(` ${line}`)); if (STRICT && total > 0) { console.error(`\n❌ 发现 ${total} 处间距/圆角不符合 AGENTS.md 的 ${TARGET}px 规则`); process.exit(1); } if (total === 0) console.log(`\n✅ 卡片圆角 / 卡片内边距 / 卡片间隔 / 页面留白 全部为 ${TARGET}px`);