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
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`);
|
|
|