## 阻断性缺陷 - list.vue 是 0 字节空文件、slist.vue 与 search/search.vue 从未存在, 而前者是 tabBar 首页、后者是 tabBar「搜索」页 —— 开屏即白屏。 按 .nvue 原型与详情页契约重建三页(CSS 渐变主视觉、分类筛选、搜索历史/热搜/联想)。 - parse-image-url.js 对空封面调 undefined.startsWith 直接抛错,列表页整页崩。 - 云函数目录缺 uni-cms-articles / uni-cms-categories / uni-cms-unlock-record schema 与 schema.ext.js,线上内容渲染与解锁逻辑无配置可用。 ## 越权与数据一致性 - uni-cms-articles:del/update/add 全部无鉴权,未登录即可删任意文章、 改他人文章作者与阅读量。补 login + 作者归属校验,作者与计数改为服务端取值。 - comments.likeComment/relikeComment:直接采信客户端传入的 user_id, 可冒名点赞刷计数。改为以令牌为准,并纳入事务。 - comments.updateComment:对数组取 .author_id,权限判断恒失败; 字段名 updateTime 与 ip_location 类型与 schema 不符。 - comments.deleteComment:`!root_id === 0` 优先级错误导致计数恒不减; 且误更新 uni-cms-articles、按不存在的 type 字段删点赞明细产生孤儿数据。 - cms-articles-like/collect:查重条件混入本次请求时间戳,防重永远失效, 可无限重复刷计数;补唯一索引并事务化。 - cms-vote:读-改-写票数导致并发丢票,记录与统计非原子;改为事务 + 原子自增。 - cms-articles-log:忽略传入 user_id 直接返回全表,泄露全站浏览记录。 - article_info:get() 使用未定义变量必崩;读接口全部无鉴权。 - user-info:公开资料接口可查任意用户 last_login_ip。 ## 资源与数据 - 全项目清空失效的签名外链(expire_at 均为 2025-03,必然 403), 改为本地生成资源:6 套文章模板、8 个编辑器图标、2 张文章配图。 - 新增分类 / 模板 / 礼物 / 热搜词种子数据,并在 db_init.json 登记, 同时补上点赞、收藏、投票、浏览日志的唯一索引。 ## 功能 - 草稿箱:预览页拆出「发布」与「存为草稿」,作品列表按状态筛选并显示徽标。 原实现有 4 个 tab 但只有 1 个有内容,且 article_status 在 UI 上无体现。 - 编辑中断恢复:接上原本空实现的「编辑草稿」回调,区分新建与编辑已有文章。 ## 工具 - tools/audit-project.js:编码 / 页面路由 / 云调用 / 云函数鉴权 / 敏感信息检查 - tools/check-vue.js:SFC 脚本语法(词法扫描处理 import·export 与条件编译) - tools/verify.js:一键验证;两个检查器各带自测,防止"永远通过" - tools/gen-*.py:模板与图标资源生成脚本
540 lines
17 KiB
JavaScript
540 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
/**
|
||
* Vue SFC 结构与脚本语法校验。
|
||
*
|
||
* audit-project.js 只做交叉引用检查,不解析 <script> 里的 JS。
|
||
* 这个脚本补上那一段:把每个 .vue 拆成 template / script / style,
|
||
* 对 <script> 做真正的语法解析,并做几项结构性检查。
|
||
*
|
||
* 用法:
|
||
* node tools/check-vue.js # 人类可读
|
||
* node tools/check-vue.js --json
|
||
* node tools/check-vue.js --selftest # 验证校验器本身有效
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const vm = require('vm');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
const JSON_OUT = process.argv.includes('--json');
|
||
const SELFTEST = process.argv.includes('--selftest');
|
||
|
||
const IGNORE_DIRS = new Set(['.git', 'node_modules', 'unpackage', '.hbuilderx', '.trae', 'git']);
|
||
|
||
const errors = [];
|
||
const warnings = [];
|
||
|
||
function walk(dir, out = []) {
|
||
let entries;
|
||
try {
|
||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
} catch {
|
||
return out;
|
||
}
|
||
for (const e of entries) {
|
||
if (e.isDirectory()) {
|
||
if (IGNORE_DIRS.has(e.name)) continue;
|
||
walk(path.join(dir, e.name), out);
|
||
} else if (e.isFile() && e.name.endsWith('.vue')) {
|
||
out.push(path.join(dir, e.name));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/');
|
||
|
||
/** 官方/第三方模块只记警告 */
|
||
const VENDOR_PREFIXES = [
|
||
'pages3/uni_modules/',
|
||
'js_sdk/',
|
||
'components/thorui/', // 第三方 UI 库整套引入
|
||
'components/hqs-popup/',
|
||
];
|
||
|
||
function isVendor(r) {
|
||
if (VENDOR_PREFIXES.some((p) => r.startsWith(p))) return true;
|
||
const m = r.match(/^uni_modules\/([^/]+)\//);
|
||
if (m && !['uni-cms-article', 'hb-comment'].includes(m[1])) return true;
|
||
return false;
|
||
}
|
||
|
||
/** 取出 SFC 的各个顶层块 */
|
||
function splitSFC(src) {
|
||
const blocks = { template: [], script: [], style: [] };
|
||
const re = /<(template|script|style)([^>]*)>/gi;
|
||
let m;
|
||
while ((m = re.exec(src))) {
|
||
const tag = m[1].toLowerCase();
|
||
const attrs = m[2];
|
||
// 自闭合的 <template /> 不是块
|
||
if (/\/\s*$/.test(attrs)) continue;
|
||
const end = src.indexOf(`</${tag}>`, m.index);
|
||
if (end === -1) continue;
|
||
blocks[tag].push({ attrs, body: src.slice(m.index + m[0].length, end), start: m.index });
|
||
re.lastIndex = end;
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
/**
|
||
* 解析脚本语法。
|
||
* SFC 的 <script> 是 ESM(含 import/export),Function 构造器解析不了,
|
||
* 因此这里把 import/export 改写成等价的可解析形式后再做语法检查。
|
||
*/
|
||
/**
|
||
* 把 ESM 源码转成一段可被 vm.Script 解析的等效代码。
|
||
*
|
||
* 关键点:不能对源码做文本替换——字符串字面量、注释、模板字符串里的
|
||
* "import"/"export" 字样都会被误伤。因此这里用词法扫描定位真正的关键字。
|
||
*/
|
||
/**
|
||
* 从 open 位置开始,找到与之配对的闭合符号的下一个位置。
|
||
* 跳过字符串、模板字符串、注释,避免被其中的括号误导。
|
||
* 返回闭合符号之后的下标,找不到返回 -1。
|
||
*/
|
||
function matchBrace(code, open, openCh, closeCh) {
|
||
let depth = 0;
|
||
let i = open;
|
||
const n = code.length;
|
||
|
||
while (i < n) {
|
||
const c = code[i];
|
||
|
||
if (c === '/' && code[i + 1] === '/') {
|
||
const nl = code.indexOf('\n', i);
|
||
i = nl === -1 ? n : nl;
|
||
continue;
|
||
}
|
||
if (c === '/' && code[i + 1] === '*') {
|
||
const end = code.indexOf('*/', i + 2);
|
||
i = end === -1 ? n : end + 2;
|
||
continue;
|
||
}
|
||
if (c === '"' || c === "'" || c === '`') {
|
||
let j = i + 1;
|
||
while (j < n) {
|
||
if (code[j] === '\\') {
|
||
j += 2;
|
||
continue;
|
||
}
|
||
if (code[j] === c) break;
|
||
j++;
|
||
}
|
||
i = j + 1;
|
||
continue;
|
||
}
|
||
|
||
if (c === openCh) depth++;
|
||
else if (c === closeCh) {
|
||
depth--;
|
||
if (depth === 0) return i + 1;
|
||
}
|
||
i++;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
function stripModuleSyntax(code) {
|
||
let out = '';
|
||
let i = 0;
|
||
let defaultCount = 0;
|
||
const n = code.length;
|
||
|
||
const isIdentChar = (c) => /[A-Za-z0-9_$]/.test(c);
|
||
|
||
while (i < n) {
|
||
const c = code[i];
|
||
|
||
// 行注释
|
||
if (c === '/' && code[i + 1] === '/') {
|
||
const end = code.indexOf('\n', i);
|
||
const stop = end === -1 ? n : end;
|
||
out += code.slice(i, stop);
|
||
i = stop;
|
||
continue;
|
||
}
|
||
// 块注释
|
||
if (c === '/' && code[i + 1] === '*') {
|
||
const end = code.indexOf('*/', i + 2);
|
||
const stop = end === -1 ? n : end + 2;
|
||
out += code.slice(i, stop);
|
||
i = stop;
|
||
continue;
|
||
}
|
||
// 字符串 / 模板字符串:原样保留,内部内容不参与关键字识别
|
||
if (c === '"' || c === "'" || c === '`') {
|
||
let j = i + 1;
|
||
while (j < n) {
|
||
if (code[j] === '\\') {
|
||
j += 2;
|
||
continue;
|
||
}
|
||
if (code[j] === c) break;
|
||
j++;
|
||
}
|
||
out += code.slice(i, Math.min(j + 1, n));
|
||
i = j + 1;
|
||
continue;
|
||
}
|
||
|
||
// import / export 关键字(必须是独立词)
|
||
const atWordStart = i === 0 || !isIdentChar(code[i - 1]);
|
||
if (atWordStart && code.startsWith('import', i) && !isIdentChar(code[i + 6] || '')) {
|
||
// import 语句一定以模块说明符(引号)结尾,后跟可选分号。
|
||
// 从 import 起找到第一个字符串字面量即为语句终点,这样
|
||
// 多行 `import {\n a,\n b\n} from 'x'` 与单行写法都能正确处理。
|
||
let j = i + 6;
|
||
let end = -1;
|
||
while (j < n) {
|
||
const ch = code[j];
|
||
if (ch === '"' || ch === "'") {
|
||
let k = j + 1;
|
||
while (k < n && code[k] !== ch) {
|
||
if (code[k] === '\\') k++;
|
||
k++;
|
||
}
|
||
end = k + 1;
|
||
break;
|
||
}
|
||
// 分号早于任何字符串说明这不是 import 语句,交给正常流程
|
||
if (ch === ';') break;
|
||
j++;
|
||
}
|
||
if (end === -1) {
|
||
out += c;
|
||
i++;
|
||
continue;
|
||
}
|
||
// 吞掉尾随分号
|
||
if (code[end] === ';') end++;
|
||
// 用等量换行占位,保持行号不变
|
||
out += code.slice(i, end).replace(/[^\n]/g, '');
|
||
i = end;
|
||
continue;
|
||
}
|
||
|
||
if (atWordStart && code.startsWith('export', i) && !isIdentChar(code[i + 6] || '')) {
|
||
let j = i + 6;
|
||
while (j < n && /[ \t]/.test(code[j])) j++;
|
||
if (code.startsWith('default', j) && !isIdentChar(code[j + 7] || '')) {
|
||
// `export default X` 的 X 若是对象/函数字面量,裸露会变成块语句或语法错误,
|
||
// 因此包成 `void ( X )` 保持表达式语义。这里扫描出 X 的结束位置后立即闭合。
|
||
let k = j + 7;
|
||
while (k < n && /[\s]/.test(code[k])) k++;
|
||
|
||
let exprEnd;
|
||
const ch = code[k];
|
||
if (ch === '{') {
|
||
exprEnd = matchBrace(code, k, '{', '}');
|
||
} else if (ch === '[') {
|
||
exprEnd = matchBrace(code, k, '[', ']');
|
||
} else if (ch === '(') {
|
||
exprEnd = matchBrace(code, k, '(', ')');
|
||
} else {
|
||
// 标识符 / 字面量:读到行尾或分号
|
||
let m = k;
|
||
while (m < n && code[m] !== '\n' && code[m] !== ';') m++;
|
||
exprEnd = m;
|
||
}
|
||
|
||
if (exprEnd === -1) {
|
||
out += c;
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
defaultCount++;
|
||
const expr = code.slice(k, exprEnd);
|
||
out += 'void (';
|
||
// 表达式内容按原样追加,但保持内部换行以维持行号
|
||
out += expr;
|
||
out += ')';
|
||
i = exprEnd;
|
||
if (code[i] === ';') i++;
|
||
continue;
|
||
}
|
||
// export const/function/class/... -> 去掉 export 关键字
|
||
out += '';
|
||
i = i + 6;
|
||
continue;
|
||
}
|
||
|
||
out += c;
|
||
i++;
|
||
}
|
||
|
||
return { code: out, defaultCount };
|
||
}
|
||
|
||
function checkScriptSyntax(code, name) {
|
||
let s = code;
|
||
|
||
// uni-app 条件编译:同一脚本里,不同平台分支会重复声明同名变量,
|
||
// 全量解析必然误报"重复声明"。丢弃 #ifdef 平台专属分支,保留通用分支。
|
||
s = s.replace(/\/\/\s*#ifdef[^\n]*\n[\s\S]*?\/\/\s*#endif/g, '');
|
||
s = s.replace(/\/\/\s*#(?:ifndef|endif)[^\n]*/g, '');
|
||
|
||
// stripModuleSyntax 只删除模块语法本身,不改变代码结构
|
||
const { code: stripped } = stripModuleSyntax(s);
|
||
|
||
try {
|
||
new vm.Script(stripped, { filename: name });
|
||
return null;
|
||
} catch (e) {
|
||
return e.message;
|
||
}
|
||
}
|
||
|
||
const lineOf = (src, idx) => src.slice(0, idx).split('\n').length;
|
||
|
||
function checkFile(file) {
|
||
const r = rel(file);
|
||
const src = fs.readFileSync(file, 'utf8');
|
||
const vendor = isVendor(r);
|
||
|
||
const report = (level, msg, line) => {
|
||
const target = vendor || level === 'warn' ? warnings : errors;
|
||
const key = `${r}|${line}|${msg}`;
|
||
if (target.some((x) => x.key === key)) return;
|
||
target.push({ key, file: r, line, msg });
|
||
};
|
||
|
||
const blocks = splitSFC(src);
|
||
|
||
if (!blocks.template.length && !blocks.script.length) {
|
||
report('error', '文件既没有 <template> 也没有 <script>,可能是空文件或损坏', 1);
|
||
return;
|
||
}
|
||
|
||
// 1) 每个 script 块做语法解析
|
||
for (const blk of blocks.script) {
|
||
if (!blk.body.trim()) {
|
||
report('error', '<script> 块为空', lineOf(src, blk.start));
|
||
continue;
|
||
}
|
||
// TS / UTS 需要对应编译器,跳过语法解析(用 JS 规则解析必然误报)
|
||
const langMatch = /\blang\s*=\s*["']([^"']+)["']/.exec(blk.attrs);
|
||
const lang = langMatch ? langMatch[1].toLowerCase() : 'js';
|
||
if (lang !== 'js' && lang !== 'javascript') {
|
||
report('warn', `<script lang="${lang}">:跳过 JS 语法校验(需 ${lang.toUpperCase()} 编译器)`, lineOf(src, blk.start));
|
||
continue;
|
||
}
|
||
const err = checkScriptSyntax(blk.body, r);
|
||
if (err) {
|
||
report('error', `脚本语法错误: ${err}`, lineOf(src, blk.start));
|
||
}
|
||
}
|
||
|
||
// 2) 整文件是否残留未闭合的 <template>(真结构性错误)
|
||
const tmplOpen = (src.match(/<template[\s>]/g) || []).length;
|
||
const tmplClose = (src.match(/<\/template>/g) || []).length;
|
||
if (tmplOpen !== tmplClose) {
|
||
report('error', `<template> 标签未闭合(开 ${tmplOpen} / 闭 ${tmplClose})`, 1);
|
||
}
|
||
|
||
// 3) Vue2 已移除的 API
|
||
const vue2Apis = [
|
||
[/\bbeforeDestroy\s*[(:]/, 'beforeDestroy 已移除,改用 beforeUnmount'],
|
||
[/\bdestroyed\s*\(/, 'destroyed 已移除,改用 unmounted'],
|
||
[/\$children\b/, '$children 已移除'],
|
||
[/\bfilters\s*:/, 'filters 已移除,改用计算属性或方法'],
|
||
[/v-on\s*:\s*\w+\.native/, 'v-on.native 已移除'],
|
||
[/Vue\.prototype\./, 'Vue.prototype 已移除,改用 app.config.globalProperties'],
|
||
];
|
||
for (const blk of blocks.script) {
|
||
for (const [re, msg] of vue2Apis) {
|
||
const m = re.exec(blk.body);
|
||
if (m) report('warn', msg, lineOf(src, blk.start) + blk.body.slice(0, m.index).split('\n').length - 1);
|
||
}
|
||
}
|
||
|
||
// 4) 页面级生命周期如果被写在 methods 内部,不会触发。
|
||
// 判断方式:该函数缩进比 methods 更深,说明确实嵌套在 methods 里。
|
||
for (const blk of blocks.script) {
|
||
const body = blk.body;
|
||
const methodsMatch = /^[ \t]*methods\s*:/m.exec(body);
|
||
if (!methodsMatch) continue;
|
||
const methodsIndent = /^([ \t]*)/.exec(methodsMatch[0])[1].length;
|
||
|
||
for (const hook of ['onReachBottom', 'onPullDownRefresh', 'onPageScroll', 'onShareAppMessage']) {
|
||
const re = new RegExp(`^([ \\t]*)${hook}\\s*\\(`, 'm');
|
||
const m = re.exec(body);
|
||
if (!m) continue;
|
||
// 缩进比 methods 更深 => 嵌在 methods 里,不会被 uni-app 触发
|
||
if (m[1].length > methodsIndent) {
|
||
report(
|
||
'error',
|
||
`${hook} 写在 methods 内不会触发,应移到页面级选项`,
|
||
lineOf(src, blk.start) + body.slice(0, m.index).split('\n').length - 1
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function selfTest() {
|
||
const cases = [];
|
||
const check = (label, actual, expected) => cases.push({ label, ok: actual === expected, actual, expected });
|
||
|
||
// 正常 SFC 不该报错
|
||
const good = `<template><view>{{a}}</view></template>
|
||
<script>
|
||
export default { data(){ return { a: 1 } } }
|
||
</script>`;
|
||
const goodBlocks = splitSFC(good);
|
||
check('正常 SFC 能拆出 template', goodBlocks.template.length, 1);
|
||
check('正常 SFC 能拆出 script', goodBlocks.script.length, 1);
|
||
check('正常脚本语法通过', checkScriptSyntax(goodBlocks.script[0].body, 'good'), null);
|
||
|
||
// 语法错误的脚本必须被抓到
|
||
const bad = `<template><view/></template>
|
||
<script>
|
||
export default { data(){ return { a: } } }
|
||
</script>`;
|
||
const badBlocks = splitSFC(bad);
|
||
check('错误脚本语法被识别', checkScriptSyntax(badBlocks.script[0].body, 'bad') !== null, true);
|
||
|
||
// 空 script 应被识别
|
||
const empty = `<template><view/></template><script></script>`;
|
||
check('空 script 被识别', splitSFC(empty).script[0].body.trim().length, 0);
|
||
|
||
// 自闭合 template 不应被当成块
|
||
const selfClosed = `<template><view/></template>`;
|
||
check('自闭合 template 不计入块', splitSFC(selfClosed).template.filter((b) => b.body.includes('</')).length, 0);
|
||
|
||
// import/export 处理:多行、字符串内含关键字、注释内含关键字
|
||
const multiImport = `
|
||
import {
|
||
a,
|
||
b
|
||
} from '@/x'
|
||
import y from '@/y';
|
||
export default {
|
||
data() {
|
||
return { a, b, y }
|
||
}
|
||
}`;
|
||
check('多行 import 被正确剥离', checkScriptSyntax(multiImport, 'multi'), null);
|
||
|
||
const trickyStrings = `
|
||
const s = "import { a } from 'fake'"
|
||
const t = 'export default nope'
|
||
const u = \`import x from "y"\`
|
||
export default { data(){ return { s, t, u } } }`;
|
||
check('字符串中的 import/export 不误伤', checkScriptSyntax(trickyStrings, 'tricky'), null);
|
||
|
||
const trickyComments = `
|
||
// import fake from 'nope'
|
||
/* export default alsoFake */
|
||
export default { data(){ return { ok: 1 } } }`;
|
||
check('注释中的 import/export 不误伤', checkScriptSyntax(trickyComments, 'comment'), null);
|
||
|
||
const condCompile = `
|
||
export default {
|
||
methods: {
|
||
init() {
|
||
// #ifdef MP-TOUTIAO
|
||
let info = tt.getRect()
|
||
// #endif
|
||
// #ifndef MP-TOUTIAO
|
||
let info = uni.getRect()
|
||
// #endif
|
||
}
|
||
}
|
||
}`;
|
||
check('条件编译分支不误报重复声明', checkScriptSyntax(condCompile, 'cond'), null);
|
||
|
||
// 行号必须保持:剥离 import 后不应改变后续代码的行位置
|
||
const lineKeep = `import a from 'b'\nimport c from 'd'\nconst boom = )`;
|
||
const errMsg = checkScriptSyntax(lineKeep, 'linekeep');
|
||
check('语法错误仍然能被发现', errMsg !== null, true);
|
||
|
||
// 生命周期钩子缩进判定:页面级(与 methods 同级)不应报错
|
||
const pageLevelHook = `export default {
|
||
methods: {
|
||
loadMore() {}
|
||
},
|
||
onReachBottom() {
|
||
this.loadMore()
|
||
}
|
||
}`;
|
||
check('页面级 onReachBottom 不误报', indentCheck(pageLevelHook, 'onReachBottom'), false);
|
||
|
||
// 嵌在 methods 里应当报错
|
||
const nestedHook = `export default {
|
||
methods: {
|
||
onReachBottom() {
|
||
this.loadMore()
|
||
}
|
||
}
|
||
}`;
|
||
check('methods 内的 onReachBottom 被识别', indentCheck(nestedHook, 'onReachBottom'), true);
|
||
|
||
return cases;
|
||
}
|
||
|
||
/** 复用主流程的缩进判定逻辑,供自测使用 */
|
||
function indentCheck(body, hook) {
|
||
const methodsMatch = /^[ \t]*methods\s*:/m.exec(body);
|
||
if (!methodsMatch) return false;
|
||
const methodsIndent = /^([ \t]*)/.exec(methodsMatch[0])[1].length;
|
||
const m = new RegExp(`^([ \\t]*)${hook}\\s*\\(`, 'm').exec(body);
|
||
if (!m) return false;
|
||
return m[1].length > methodsIndent;
|
||
}
|
||
|
||
function main() {
|
||
const files = walk(ROOT);
|
||
|
||
if (SELFTEST) {
|
||
const cases = selfTest();
|
||
console.log('');
|
||
console.log('Vue 校验器自测:');
|
||
for (const c of cases) {
|
||
console.log(` ${c.ok ? '✓' : '✗'} ${c.label}${c.ok ? '' : `(期望 ${c.expected},实得 ${c.actual})`}`);
|
||
}
|
||
const failed = cases.filter((c) => !c.ok).length;
|
||
console.log('');
|
||
console.log(failed ? `✗ ${failed} 项失败` : '✓ 校验器工作正常');
|
||
return failed ? 1 : 0;
|
||
}
|
||
|
||
for (const f of files) checkFile(f);
|
||
|
||
if (JSON_OUT) {
|
||
console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings, scanned: files.length }, null, 2));
|
||
return errors.length ? 1 : 0;
|
||
}
|
||
|
||
console.log('');
|
||
console.log('═══════════════════════════════════════════════');
|
||
console.log(' Vue SFC 校验报告');
|
||
console.log('═══════════════════════════════════════════════');
|
||
console.log(` 扫描 ${files.length} 个 .vue 文件\n`);
|
||
|
||
const fmt = (list) =>
|
||
list.map((x) => ` ${x.file}:${x.line}\n ▸ ${x.msg}`).join('\n');
|
||
|
||
if (errors.length) {
|
||
console.log(`✗ 错误 ${errors.length} 项`);
|
||
console.log(fmt(errors));
|
||
console.log('');
|
||
}
|
||
if (warnings.length) {
|
||
console.log(`⚠ 警告 ${warnings.length} 项`);
|
||
console.log(fmt(warnings));
|
||
console.log('');
|
||
}
|
||
if (!errors.length && !warnings.length) console.log('✓ 全部通过\n');
|
||
|
||
console.log(`结论: ${errors.length} 错误 / ${warnings.length} 警告`);
|
||
console.log('═══════════════════════════════════════════════');
|
||
return errors.length ? 1 : 0;
|
||
}
|
||
|
||
process.exit(main());
|