轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

239 lines
11 KiB

/**
* 间距与圆角校验:核对 `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`);