CRMEB/frontend/uni-app/scripts/check-naming.mjs
From-wh 787ecc2400 feat(移动端): 命名检查与规范覆盖 pages-modules 插件模块
check-naming 目录规则覆盖 pages-modules(snake_case 页面目录、_ 前缀排除区),并强制页面入口唯一形态:非排除位置的 .vue 必须 index.vue,防止 PascalCase 文件被扫描器误注册为路由。docs/命名规范.md 新增 §2.1 插件模块命名细则(模块目录=插件标识、语义后缀惯例、固定文件)与常用营销插件对照表(seckill/combination/bargain/presell/integral/coupon/sign/lottery/distribution)。
2026-10-08 16:02:15 +08:00

134 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* 命名规范检查(零依赖)。规则与豁免清单见 docs/命名规范.md。
*
* 用法:
* npm run lint:naming 检查模式:白名单外的违规报错并 exit 1
* npm run lint:naming:update 重建存量白名单 scripts/naming-baseline.json
*/
import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const SRC = join(ROOT, 'src');
const BASELINE_FILE = join(ROOT, 'scripts', 'naming-baseline.json');
const UPDATE = process.argv.includes('--update-baseline');
// 豁免前缀(相对 src/):第三方库与契约冻结层,见规范 §3-5
const EXEMPT_PREFIXES = [
'subpackage/diyComponents',
'components/mp-html',
'components/easy-loadimage',
'components/zb-code',
'pages/annex/components/verify',
'pages/users/components/verify',
'static/plugin',
'static/fonts',
];
const VUE_RE = /^[A-Z][a-zA-Z0-9]*$/;
const JS_RE = /^[a-z][a-zA-Z0-9]*$/;
const IMG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const TOP_DIR_RE = /^[a-z][a-z0-9-]*$/;
const PAGE_DIR_RE = /^[a-z][a-z0-9_]*$/;
const PASCAL_DIR_RE = /^[A-Z][a-zA-Z0-9]*$/;
const IMG_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']);
// pages 树中的角色目录:其内部按文件规则校验,不再按页面目录名规则校验
const ROLE_DIRS = new Set(['components', 'component', 'static']);
const violations = new Map(); // relpath -> 规则说明
const rel = (p) => relative(ROOT, p).split(sep).join('/');
const isExempt = (relPath) =>
EXEMPT_PREFIXES.some((pre) => relPath === `src/${pre}` || relPath.startsWith(`src/${pre}/`));
const addViolation = (relPath, rule) => {
if (!isExempt(relPath)) violations.set(relPath, rule);
};
function walk(dir, files, dirs) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) {
dirs.push(p);
walk(p, files, dirs);
} else {
files.push(p);
}
}
}
const files = [];
const dirs = [];
walk(SRC, files, dirs);
for (const f of files) {
const relPath = rel(f);
const name = relPath.split('/').pop();
const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
if (name.includes('.min.')) continue; // 压缩版第三方库
if (ext === '.vue' && name !== 'index.vue' && !VUE_RE.test(name.replace(/\.vue$/, ''))) {
addViolation(relPath, '.vue 组件须 PascalCase(或 index.vue)');
} else if ((ext === '.js' || ext === '.mjs') && !JS_RE.test(name.replace(/\.(m?js)$/, ''))) {
addViolation(relPath, 'js 模块须 camelCase 或全小写单词');
} else if (IMG_EXT.has(ext)) {
const stem = name.slice(0, name.length - ext.length);
if (!IMG_RE.test(stem) || !/[a-z]/.test(stem)) addViolation(relPath, '图片须 kebab-case 全小写且含字母(禁下划线/大写/拼音/纯数字)');
}
// pages-modules 页面入口唯一形态:snake_case 目录 + index.vue(PascalCase 文件会被误注册为路由)
if (ext === '.vue' && name !== 'index.vue' && relPath.startsWith('src/pages-modules/')) {
const segs = relPath.split('/');
const inner = segs.slice(2, -1); // ['pages-modules', 模块名, ...页面目录祖先]
if (!inner.some((s) => ROLE_DIRS.has(s) || s.startsWith('_'))) {
addViolation(relPath, 'pages-modules/ 页面必须为 snake_case 目录 + index.vue(局部组件放 components/,细则见 docs/命名规范.md §2.1)');
}
}
}
for (const d of dirs) {
const relPath = rel(d);
const segs = relPath.split('/'); // ['src', ...]
const name = segs[segs.length - 1];
if (segs.length === 2) {
if (!TOP_DIR_RE.test(name)) addViolation(relPath, 'src 顶层目录须全小写或 kebab-case');
} else if (segs[1] === 'pages' || segs[1] === 'pages-modules') {
const inner = segs.slice(2, -1); // 该目录的所有祖先段
// pages-modules 下 `_` 前缀目录为扫描器排除区(_template 模板等),不按页面目录规则校验
const exempt =
inner.some((s) => ROLE_DIRS.has(s)) ||
(segs[1] === 'pages-modules' && (name.startsWith('_') || inner.some((s) => s.startsWith('_'))));
if (!exempt && !PAGE_DIR_RE.test(name)) {
addViolation(relPath, 'pages/(及 pages-modules/)页面目录须 snake_case 或全小写单词');
}
} else if (segs[1] === 'components' && segs.length === 3 && !PASCAL_DIR_RE.test(name)) {
addViolation(relPath, 'components/ 组件目录须 PascalCase');
}
}
const sorted = [...violations.keys()].sort();
let baseline = { generatedAt: null, entries: [] };
try {
baseline = JSON.parse(readFileSync(BASELINE_FILE, 'utf8'));
} catch {
/* 首次运行,白名单为空 */
}
const baselineSet = new Set(baseline.entries);
if (UPDATE) {
writeFileSync(BASELINE_FILE, `${JSON.stringify({ generatedAt: new Date().toISOString(), entries: sorted }, null, '\t')}\n`);
console.log(`白名单已重建:${sorted.length} 项存量违规(原 ${baseline.entries.length} 项)→ scripts/naming-baseline.json`);
process.exit(0);
}
const fresh = sorted.filter((p) => !baselineSet.has(p));
const stale = baseline.entries.filter((p) => !violations.has(p));
if (fresh.length) {
console.error(`✖ 命名规范违规 ${fresh.length} 处(存量白名单之外,规则见 docs/命名规范.md):`);
for (const p of fresh) console.error(` ${p} — ${violations.get(p)}`);
process.exit(1);
}
console.log(`✔ 命名规范检查通过(白名单存量违规 ${sorted.length} 项)。`);
if (stale.length) {
console.log(`提示:${stale.length} 项白名单条目已失效,可运行 npm run lint:naming:update 清理。`);
}