Files
rednote-tool-studio/lib/validation.ts
T
2026-09-17 21:54:33 +08:00

98 lines
6.4 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { SKILL_ROOT, SKILL_VERSION } from "./config";
import type { ValidationIssue, ValidationReport } from "./types";
const ALLOWED_EXTENSIONS = new Set([".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".woff", ".woff2", ".json"]);
const FORBIDDEN = [
["NETWORK_FETCH", /\bfetch\s*\(|\bXMLHttpRequest\b|\bnew\s+(?:WebSocket|EventSource|RTCPeerConnection)\s*\(/, "删除联网逻辑,改用包内资源或本地数据"],
["WORKER_FORBIDDEN", /\bnew\s+(?:Worker|SharedWorker)\s*\(|navigator\.serviceWorker/, "将逻辑放回主线程"],
["DYNAMIC_CODE", /\beval\s*\(|\bnew\s+Function\s*\(|\bWebAssembly\b/, "改为静态 JavaScript 逻辑"],
["DEVICE_API", /navigator\.(?:geolocation|clipboard|bluetooth|usb|hid|serial|getBattery|connection|credentials|locks)/, "移除容器禁用的设备能力"],
["WINDOW_API", /window\.(?:open|prompt)\s*\(|\.requestFullscreen\s*\(/, "使用单页视图或页内弹层"],
] as const;
async function collectFiles(root: string) {
const files: string[] = [];
async function walk(dir: string) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full); else files.push(full);
}
}
await walk(root);
return files;
}
function lineOf(text: string, index: number) { return text.slice(0, index).split("\n").length; }
function runAudit(target: string) {
return new Promise<string>((resolve) => {
const child = spawn(process.execPath, [path.join(SKILL_ROOT, "scripts", "audit_artifact.mjs"), target]);
let output = "";
child.stdout.on("data", (chunk) => { output += chunk; });
child.stderr.on("data", (chunk) => { output += chunk; });
child.on("close", () => resolve(output.trim()));
child.on("error", (error) => resolve(`Audit unavailable: ${error.message}`));
});
}
export async function validateArtifact(distRoot: string, zipPath?: string): Promise<ValidationReport> {
const issues: ValidationIssue[] = [];
const files = await collectFiles(distRoot);
const rel = (file: string) => path.relative(distRoot, file).split(path.sep).join("/");
if (!files.some((file) => rel(file) === "index.html")) issues.push({ severity: "error", code: "MISSING_INDEX", file: "index.html", message: "ZIP 根目录缺少 index.html", suggestion: "将入口文件输出到产物根目录" });
let unpackedBytes = 0;
for (const file of files) {
const name = rel(file);
const stat = await fs.stat(file);
unpackedBytes += stat.size;
if (!ALLOWED_EXTENSIONS.has(path.extname(file).toLowerCase())) issues.push({ severity: "error", code: "UNSUPPORTED_FILE", file: name, message: "产物包含不支持的文件类型", suggestion: "从最终 ZIP 中移除此文件" });
if (![".html", ".css", ".js", ".json"].includes(path.extname(file))) continue;
const text = await fs.readFile(file, "utf8");
for (const [code, pattern, suggestion] of FORBIDDEN) {
const match = pattern.exec(text);
if (match) issues.push({ severity: "error", code, file: name, line: lineOf(text, match.index), message: `发现受限能力:${match[0]}`, suggestion });
}
if (path.extname(file) === ".html") {
const checks: Array<[string, RegExp, string]> = [
["INLINE_SCRIPT", /<script(?![^>]*\bsrc=)[^>]*>/i, "将脚本移动到包内 .js 文件"],
["INLINE_EVENT", /\son[a-z]+\s*=/i, "使用 addEventListener 或 React 事件"],
["MODULE_SCRIPT", /<script[^>]+type=["']module["']/i, "构建为普通经典脚本"],
["FORBIDDEN_ELEMENT", /<(?:iframe|object|base)\b/i, "移除容器不支持的元素"],
];
for (const [code, pattern, suggestion] of checks) {
const match = pattern.exec(text);
if (match) issues.push({ severity: "error", code, file: name, line: lineOf(text, match.index), message: `HTML 不符合离线规范:${match[0]}`, suggestion });
}
const resourcePattern = /(?:src|href)=["']([^"']+)["']/gi;
for (const match of text.matchAll(resourcePattern)) {
const resource = match[1];
if (/^https?:\/\//i.test(resource)) issues.push({ severity: "error", code: "EXTERNAL_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `发现外部资源:${resource}`, suggestion: "下载资源并改为 ./ 开头的包内相对路径" });
else if (resource.startsWith("./")) {
const target = path.resolve(path.dirname(file), resource);
if (!target.startsWith(`${distRoot}${path.sep}`) || !(await fs.stat(target).catch(() => null))?.isFile()) issues.push({ severity: "error", code: "MISSING_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `引用的包内资源不存在:${resource}`, suggestion: "修正路径或将资源加入产物" });
}
}
}
if (path.extname(file) === ".css") {
const urlPattern = /url\(\s*["']?([^"')]+)["']?\s*\)/gi;
for (const match of text.matchAll(urlPattern)) {
const resource = match[1];
if (/^https?:\/\//i.test(resource)) issues.push({ severity: "error", code: "EXTERNAL_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `发现外部资源:${resource}`, suggestion: "下载资源并使用包内相对路径" });
else if (!/^(?:data:|blob:|#)/i.test(resource)) {
const target = path.resolve(path.dirname(file), resource);
if (!target.startsWith(`${distRoot}${path.sep}`) || !(await fs.stat(target).catch(() => null))?.isFile()) issues.push({ severity: "error", code: "MISSING_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `引用的包内资源不存在:${resource}`, suggestion: "修正路径或将资源加入产物" });
}
}
}
}
const directoryAudit = await runAudit(distRoot);
const zipAudit = zipPath ? await runAudit(zipPath) : "";
const auditOutput = [directoryAudit, zipAudit].filter(Boolean).join("\n");
if (/\bERROR\b/.test(auditOutput)) issues.push({ severity: "error", code: "SKILL_AUDIT", file: ".", message: auditOutput, suggestion: "按 minitool-zip-builder 审计信息缩减或修复产物" });
const zipBytes = zipPath ? (await fs.stat(zipPath)).size : undefined;
return { status: issues.some((issue) => issue.severity === "error") ? "failed" : "passed", skillVersion: SKILL_VERSION, checkedAt: new Date().toISOString(), issues, auditOutput, limits: { zipBytes, unpackedBytes } };
}