Files
console-dev/scripts/check-source.mjs
T

153 lines
5.4 KiB
JavaScript

/**
* 静态源码门禁:解析应用的 JS/TS/Vue,检查显式导入和机器/旧部署路径依赖。
* 这是构建前检查,不替代浏览器业务验收,也不会启动或修改生产服务。
*/
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { parse as parseJavaScript } from "@babel/parser";
import {
parse as parseVue,
compileScript,
compileTemplate,
} from "@vue/compiler-sfc";
const root = path.resolve(import.meta.dirname, "..");
const require = createRequire(import.meta.url);
const failures = [];
const files = [];
function walk(directory) {
// 仅递归 app/;不扫描 node_modules、构建产物或本地 docs 报告。
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) walk(file);
else files.push(file);
}
}
walk(path.join(root, "app"));
const manifest = JSON.parse(
fs.readFileSync(path.join(root, "package.json"), "utf8"),
);
const dependencies = new Set(
Object.keys({ ...manifest.dependencies, ...manifest.devDependencies }),
);
function existsModule(file) {
// 与当前源码使用的简写导入形式对齐,允许目录下的 index.js / index.ts。
return ["", ".js", ".ts", ".json", ".vue", "/index.js", "/index.ts"].some(
(extension) => fs.existsSync(file + extension),
);
}
function checkImport(source, owner) {
// # 开头的虚拟模块由 Nuxt 生成,本地文件与 npm 包则分别核对实际路径及依赖声明。
if (source.startsWith("#")) return; // Nuxt-generated public/virtual modules.
if (source.startsWith("~/")) {
if (!existsModule(path.join(root, "app", source.slice(2))))
failures.push(`${owner}: missing ${source}`);
} else if (source.startsWith(".")) {
if (!existsModule(path.resolve(path.dirname(owner), source)))
failures.push(`${owner}: missing ${source}`);
} else {
const name = source.startsWith("@")
? source.split("/").slice(0, 2).join("/")
: source.split("/")[0];
if (!dependencies.has(name))
failures.push(`${owner}: undeclared dependency ${name}`);
try {
require.resolve(source);
} catch (error) {
failures.push(`${owner}: cannot resolve ${source}: ${error.message}`);
}
}
}
function inspect(node, owner) {
// 按 AST 检查,不把注释中的历史说明误判成运行时路径依赖。
if (!node || typeof node !== "object") return;
if (
[
"ImportDeclaration",
"ExportNamedDeclaration",
"ExportAllDeclaration",
].includes(node.type) &&
node.source
)
checkImport(node.source.value, owner);
if (node.type === "CallExpression" && node.callee.type === "Import") {
// 静态字符串的懒加载同样要验证;动态计算的路径不在本门禁可证明范围内。
const value = node.arguments[0];
if (value.type === "StringLiteral") checkImport(value.value, owner);
}
const literal =
node.type === "StringLiteral"
? node.value
: node.type === "TemplateElement"
? (node.value.cooked ?? node.value.raw)
: null;
if (
literal !== null &&
/(?:\.output\/|\/releases\/|\/Users\/|\/www\/wwwroot\/)/.test(literal)
)
failures.push(
`${owner}:${node.loc?.start.line ?? 1}: runtime depends on deployment/machine path`,
);
for (const [key, value] of Object.entries(node)) {
// 跳过位置和注释等元数据,仅递归语法节点,避免重复或无意义遍历。
if (["loc", "start", "end", "extra", "comments", "tokens"].includes(key))
continue;
if (Array.isArray(value)) for (const item of value) inspect(item, owner);
else if (value && typeof value === "object") inspect(value, owner);
}
}
let components = 0;
let modules = 0;
for (const file of files) {
if (!/\.(vue|js|ts)$/.test(file)) continue;
const source = fs.readFileSync(file, "utf8");
try {
if (file.endsWith(".vue")) {
// script setup 先展开编译宏,再解析脚本;有 template 时另外检查模板编译错误。
components++;
const { descriptor, errors } = parseVue(source, { filename: file });
if (errors.length) throw Error(errors.join("\n"));
const script = descriptor.scriptSetup
? compileScript(descriptor, { id: "source-check" }).content
: descriptor.script?.content || "";
inspect(
parseJavaScript(script, {
sourceType: "module",
plugins: ["typescript"],
}),
file,
);
if (descriptor.template) {
const result = compileTemplate({
source: descriptor.template.content,
filename: file,
id: "source-check",
});
if (result.errors.length) throw Error(result.errors.join("\n"));
}
} else {
modules++;
inspect(
parseJavaScript(source, {
sourceType: "module",
plugins: ["typescript"],
}),
file,
);
}
} catch (error) {
failures.push(`${file}: ${error.message}`);
}
}
// 一次报告全部失败并以非零状态退出,方便开发者和 CI 定位多个问题。
if (failures.length) {
console.error(
failures.map((failure) => failure.replaceAll(root + "/", "")).join("\n"),
);
process.exitCode = 1;
} else
console.log(
`Source gate passed: ${components} Vue files, ${modules} modules; imports resolve without any historic release.`,
);