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((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 { 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", /]*\bsrc=)[^>]*>/i, "将脚本移动到包内 .js 文件"], ["INLINE_EVENT", /\son[a-z]+\s*=/i, "使用 addEventListener 或 React 事件"], ["MODULE_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 } }; }