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.
223 lines
9.9 KiB
223 lines
9.9 KiB
/**
|
|
* 可点击控件审计:找出「看起来能点、其实没有真动作」的 Pressable / Touchable*。
|
|
*
|
|
* 背景:本项目把「不留假入口」当红线(点下去零反馈、或点了只被告知「功能开发中」,
|
|
* 都是把开发中的状态摆给用户看)。与 `check-routes.mjs` 的关系:那个查「页面能不能到达」,
|
|
* 这个查「按钮点下去有没有真办事」。
|
|
*
|
|
* 检测两类,分别对应两种历史事故:
|
|
*
|
|
* ① 无处理函数 / 空处理函数
|
|
* 控件标签内既没有 onPress / onPressIn / onLongPress / onPressOut,
|
|
* 也没有 `disabled`(禁用态是有意的——视觉上应当置灰)与属性展开({...props})。
|
|
*
|
|
* ② 弹窗式假交互(2026-09 补的盲区)
|
|
* 有 onPress,但点下去只是 Alert.alert 一句「功能开发中 / 敬请期待 / 暂未开放」。
|
|
* 旧脚本只查 ①,于是全仓 37 处这类假交互一路绿灯,文档还据此写下了
|
|
* 「死控件已清零」的错误结论 —— 这个盲区必须堵上。
|
|
*
|
|
* ② 最难的是**不误报**:`app/resume.tsx` 那类「await 调完接口、拿到 result 后如实
|
|
* 报告能力未接入」是项目 4.4 节确立的正确做法(保留入口 + 坦诚提示 + 不假装成功),
|
|
* 文案与假交互一模一样。区分轴取「处理函数里有没有真实动作」:
|
|
* 有 await / .then( / async 说明确实去问过后端,属如实报告;纯弹窗则判为假交互。
|
|
*
|
|
* 豁免标记(写在命中行、上一行,或该处理函数声明行附近均可):
|
|
* - `affordance-ok` 有意为之的控件(如弹层里 onPress={() => {}} 吞冒泡,
|
|
* 不吞就会点内容区也关掉弹层,与「忘了接线」是两回事)
|
|
* - `affordance-pending-backend` 如实标注的后端待办(如附件上传、接受 Offer):
|
|
* 确实是假交互,但属**知情保留**,非疏漏。
|
|
* 用这个标记而不是 affordance-ok,是为了让「已知欠账」
|
|
* 在检索时仍能被数出来,不会混进「有意为之」里消失。
|
|
*
|
|
* 用法:node scripts/check-affordances.mjs # 列出全部
|
|
* node scripts/check-affordances.mjs --error # 有命中则 exit 1(可用于 CI)
|
|
* node scripts/check-affordances.mjs --pending # 列出知情保留的后端待办清单
|
|
*/
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const ROOTS = ['app', 'components'];
|
|
const HANDLER_PROPS = ['onPress', 'onPressIn', 'onPressOut', 'onLongPress', 'disabled', '{...'];
|
|
|
|
/** 有意为之的控件(吞冒泡)在行内或上一行标这个,避免与「忘了接线」混在一起 */
|
|
const EXEMPT_MARKER = 'affordance-ok';
|
|
|
|
/** 如实标注的后端待办:确实是假交互,但是知情的,标记出来以便与疏漏区分 */
|
|
const PENDING_MARKER = 'affordance-pending-backend';
|
|
|
|
/** `--pending` 用它计数:注释内容**以标记开头**才算一条待办,避免说明文字被误计 */
|
|
const PENDING_LINE_RE = /^\s*(?:\/\/|\/\*+|\*+)?\s*affordance-pending-backend\b/;
|
|
|
|
/**
|
|
* 「功能还没做」的措辞。刻意放宽到「暂未开放 / 即将上线」等:
|
|
* 现在全仓的写法就是「暂未开放」,只认「功能开发中」的话,
|
|
* 下一个假按钮换个近义词就能溜过去,盲区等于没堵。
|
|
*/
|
|
const PENDING_TEXT_RE =
|
|
/功能开发中|敬请期待|暂未开放|尚未开放|即将上线|开发中|未开放|coming\s*soon|not\s+available\s+yet/i;
|
|
|
|
/** 处理函数里出现过这些,说明确实去干过活(问过后端 / 读过存储),属如实报告 */
|
|
const REAL_WORK_RE = /\bawait\b|\.then\s*\(|\basync\b/;
|
|
|
|
/** 从这里往前找处理函数的起点,用来判断「这个 Alert 是不是整个处理函数的全部内容」 */
|
|
const HANDLER_START_RE = new RegExp(
|
|
[
|
|
// onPress={() => / onPress={handleX} / onPress={cond ? a : b}
|
|
String.raw`onPress(?:In|Out)?\s*=\s*\{?`,
|
|
String.raw`onLongPress\s*=\s*\{?`,
|
|
// const handleX = async () => / const handleX = () => / function handleX() {
|
|
String.raw`(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?(?:\([^()]{0,200}\)|[A-Za-z_$][\w$]*)\s*=>`,
|
|
String.raw`function\s+[A-Za-z_$][\w$]*\s*\([^()]{0,200}\)\s*\{`,
|
|
].join('|'),
|
|
'g',
|
|
);
|
|
|
|
/** 往前回看的字符数:够覆盖一个处理函数的声明与函数体,又不至于跨到上一个函数 */
|
|
const LOOKBACK = 600;
|
|
|
|
/** 从 `<Tag` 的起始下标起,找到该开标签的结束 `>`(粗略忽略字符串内的 `>`,对本仓库足够) */
|
|
function findTagEnd(text, from) {
|
|
let depth = 0;
|
|
for (let i = from; i < text.length; i += 1) {
|
|
const ch = text[i];
|
|
if (ch === '{') depth += 1;
|
|
else if (ch === '}') depth -= 1;
|
|
else if (ch === '>' && depth === 0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/** 取该下标的 1-based 行号 */
|
|
function lineOf(text, index) {
|
|
return text.slice(0, index).split('\n').length;
|
|
}
|
|
|
|
/** 命中行 / 上一行 / 前两行里有没有写豁免标记(处理函数的说明注释一般写在声明前) */
|
|
function hasMarker(lines, line, marker) {
|
|
for (const offset of [1, 2, 3]) {
|
|
const text = lines[line - offset];
|
|
if (text && text.includes(marker)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 判断某个 Alert.alert 是不是「处理函数里唯一干的事」。
|
|
*
|
|
* 做法:往回找最近的一处处理函数起点,截取「起点 → Alert」这段。
|
|
* 这段里有 await / .then / async,说明弹窗之前确实发生过真实操作(调接口、读存储),
|
|
* 弹窗是在报告操作结果,不是在冒充功能。
|
|
*
|
|
* 这是启发式而非语法分析:本仓库的 handler 写法高度统一(内联箭头或 `const handleX = () =>`),
|
|
* 回看窗口按这个风格取值。误报的兜底是 affordance-pending-backend 标记。
|
|
*/
|
|
function isBarePendingAlert(text, index) {
|
|
const from = Math.max(0, index - LOOKBACK);
|
|
const window = text.slice(from, index);
|
|
|
|
let start = 0;
|
|
for (const match of window.matchAll(HANDLER_START_RE)) {
|
|
start = match.index;
|
|
}
|
|
const body = window.slice(start);
|
|
|
|
return !REAL_WORK_RE.test(body);
|
|
}
|
|
|
|
function walk(dir, 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);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const TAG_RE = /<(Pressable|TouchableOpacity|TouchableHighlight|TouchableWithoutFeedback)\b/g;
|
|
|
|
/** 空处理函数:绑了 onPress 但什么都不做,点下去同样零反馈 */
|
|
const EMPTY_HANDLER_RE = /onPress=\{\s*\(\s*\)\s*=>\s*(\{\s*\}|undefined|null)\s*\}/;
|
|
|
|
/** 定位所有 Alert.alert 调用(含实参),用于第 ② 类检测 */
|
|
const ALERT_RE = /Alert\.alert\s*\(/g;
|
|
|
|
const findings = [];
|
|
for (const root of ROOTS) {
|
|
for (const file of walk(root)) {
|
|
const text = readFileSync(file, 'utf8');
|
|
const lines = text.split('\n');
|
|
|
|
/* ---------- ① 无处理函数 / 空处理函数 ---------- */
|
|
for (const match of text.matchAll(TAG_RE)) {
|
|
const end = findTagEnd(text, match.index);
|
|
if (end < 0) continue;
|
|
const tag = text.slice(match.index, end + 1);
|
|
const empty = EMPTY_HANDLER_RE.test(tag);
|
|
if (!empty && HANDLER_PROPS.some((prop) => tag.includes(prop))) continue;
|
|
const line = lineOf(text, match.index);
|
|
if (hasMarker(lines, line, EXEMPT_MARKER)) continue;
|
|
findings.push({
|
|
file,
|
|
line,
|
|
reason: empty ? '空处理函数' : '无处理函数',
|
|
tag: lines[line - 1].trim(),
|
|
});
|
|
}
|
|
|
|
/* ---------- ② 弹窗式假交互 ---------- */
|
|
for (const match of text.matchAll(ALERT_RE)) {
|
|
// 只看这次 alert 的实参,别把后面别的 alert 的文案也算进来
|
|
const end = text.indexOf('\n', match.index);
|
|
const call = text.slice(match.index, end < 0 ? text.length : end);
|
|
if (!PENDING_TEXT_RE.test(call)) continue;
|
|
|
|
const line = lineOf(text, match.index);
|
|
if (hasMarker(lines, line, EXEMPT_MARKER)) continue;
|
|
if (hasMarker(lines, line, PENDING_MARKER)) continue;
|
|
if (!isBarePendingAlert(text, match.index)) continue;
|
|
|
|
findings.push({
|
|
file,
|
|
line,
|
|
reason: '弹窗式假交互',
|
|
tag: lines[line - 1].trim(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `--pending`:把「知情保留的后端待办」单独列出来。
|
|
* 这些是刻意不修的欠账(标记为 affordance-pending-backend),不该混在 0 命中的绿报里
|
|
* 假装不存在 —— 后端就绪后照着这张清单逐条兑现即可。
|
|
*/
|
|
if (process.argv.includes('--pending')) {
|
|
const pending = [];
|
|
for (const root of ROOTS) {
|
|
for (const file of walk(root)) {
|
|
const lines = readFileSync(file, 'utf8').split('\n');
|
|
lines.forEach((text, i) => {
|
|
// 只认「注释内容以标记开头」的行。若用 includes,连解释这个标记本身的说明文字
|
|
// 也会被算成一条待办,数出来的数字就是假的 —— 那正是本轮要治的病。
|
|
if (PENDING_LINE_RE.test(text)) pending.push({ file, line: i + 1, text: text.trim() });
|
|
});
|
|
}
|
|
}
|
|
console.log(`=== 知情保留的后端待办 (${pending.length}) ===`);
|
|
for (const p of pending) console.log(`${p.file}:${p.line} ${p.text}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (findings.length === 0) {
|
|
console.log('✅ 未发现假交互控件(无处理函数 / 弹窗式假交互均无)');
|
|
} else {
|
|
console.log(`=== 点下去不办真事的可点击控件 (${findings.length}) ===`);
|
|
for (const f of findings) {
|
|
console.log(`${f.file}:${f.line} [${f.reason}] ${f.tag}`);
|
|
}
|
|
console.log(
|
|
'\n说明:确属后端待办、决定知情保留的,加注释标记 `affordance-pending-backend`;' +
|
|
'有意吞冒泡的加 `affordance-ok`。',
|
|
);
|
|
if (process.argv.includes('--error')) process.exitCode = 1;
|
|
}
|
|
|