Files

336 lines
13 KiB
JavaScript
Executable File
Raw Permalink 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
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function parseArgs(argv) {
const args = {
caseDir: 'case',
manifest: '',
requireRuntimeRefresh: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--case' || a === '-d') args.caseDir = argv[++i] || 'case';
else if (a === '--manifest') args.manifest = argv[++i] || '';
else if (a === '--require-runtime-refresh') args.requireRuntimeRefresh = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_dynamic_resources.js --case-dir case --markdown
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --markdown
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --json
说明:检查 case/notes/resource-manifest.json 中的动态 HTML / JS / challenge 资源是否只作为分析快照,并确认影响最终参数生成的动态资源已设计运行时刷新模块;同时检查 result/ 中是否混入动态快照文件。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function stat(p) {
try { return fs.statSync(p); } catch { return null; }
}
function rel(root, p) {
return (path.relative(root, p) || '.').replace(/\\/g, '/');
}
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function sha256File(file) {
try {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
} catch {
return '';
}
}
function normalizeBool(v) {
return v === true || v === 'true' || v === 1 || v === '1';
}
function isDynamicValue(v) {
if (typeof v === 'boolean') return v;
if (typeof v === 'string') return ['true', 'yes', 'dynamic', 'high-risk', '高风险', '动态'].includes(v.toLowerCase());
return false;
}
function getHash(resource) {
return resource.sha256 || resource.bodySha256 || resource.bodyHash || resource.hash || '';
}
function sanitizeRel(p) {
return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '');
}
function resolveCasePath(caseDir, maybePath) {
const raw = String(maybePath || '').trim();
if (!raw) return [];
if (path.isAbsolute(raw)) return [raw];
const normalized = sanitizeRel(raw);
const parent = path.dirname(caseDir);
const withoutCase = normalized.replace(/^case\//i, '');
const candidates = [
path.join(caseDir, normalized),
path.join(parent, normalized),
path.join(caseDir, withoutCase),
];
if (!/^result\//i.test(normalized)) candidates.push(path.join(caseDir, 'result', normalized));
return [...new Set(candidates.map(p => path.normalize(p)))];
}
function firstExisting(paths) {
return paths.find(p => exists(p)) || '';
}
function readManifest(manifestPath) {
const text = fs.readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, '');
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) return { raw: parsed, resources: parsed };
const resources = Array.isArray(parsed.resources) ? parsed.resources : [];
return { raw: parsed, resources };
}
function finalEntryFiles(resultDir) {
const files = [];
for (const name of ['final.js', 'final.mjs', 'final.cjs', 'final.py']) {
const p = path.join(resultDir, name);
if (exists(p)) files.push(p);
}
return files;
}
function readTextSafe(file) {
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
}
function inspect(caseDir, args) {
const problems = [];
const warnings = [];
const manifestPath = args.manifest ? path.resolve(args.manifest) : path.join(caseDir, 'notes', 'resource-manifest.json');
const resultDir = path.join(caseDir, 'result');
const snapshotsDir = path.join(caseDir, 'js', 'snapshots');
const report = {
ok: false,
caseDir,
manifestPath,
manifestExists: exists(manifestPath),
resources: [],
dynamicCount: 0,
requiredDynamicCount: 0,
runtimeRefreshCount: 0,
problems,
warnings,
};
if (!report.manifestExists) {
const hasSnapshots = exists(snapshotsDir) && walk(snapshotsDir).length > 0;
const msg = `未找到资源清单:${rel(caseDir, manifestPath)}`;
if (args.requireRuntimeRefresh || hasSnapshots) problems.push(hasSnapshots ? `${msg};但已存在 js/snapshots 动态快照目录,必须补写 resource-manifest.json。` : `${msg};当前要求强制检查运行时刷新。`);
else warnings.push(`${msg};如果本 case 没有下载 / 保存 HTML、JS 或 challenge 资源,可以忽略。`);
report.ok = problems.length === 0;
return report;
}
let manifest;
try {
manifest = readManifest(manifestPath);
} catch (err) {
problems.push(`resource-manifest.json 解析失败:${err.message}`);
report.ok = false;
return report;
}
if (!Array.isArray(manifest.resources)) {
problems.push('resource-manifest.json 缺少 resources 数组。');
report.ok = false;
return report;
}
const resultFiles = walk(resultDir).filter(p => stat(p)?.isFile());
const resultHashes = new Map();
for (const file of resultFiles) resultHashes.set(file, sha256File(file));
const resultRelList = resultFiles.map(file => rel(caseDir, file));
for (let i = 0; i < manifest.resources.length; i++) {
const r = manifest.resources[i] || {};
const item = {
index: i,
url: r.url || '',
type: r.type || '',
file: r.file || '',
sha256: getHash(r),
dynamic: isDynamicValue(r.dynamic),
requiredForFinal: normalizeBool(r.requiredForFinal),
runtimeRefresh: normalizeBool(r.runtimeRefresh) || r.use === 'runtime-refresh',
refreshEntry: r.refreshEntry || '',
use: r.use || '',
problems: [],
warnings: [],
};
report.resources.push(item);
const prefix = `resources[${i}]`;
if (!item.url) item.problems.push(`${prefix} 缺少 url。`);
if (!item.sha256) item.problems.push(`${prefix} 缺少 sha256 / bodySha256 / hash。`);
if (typeof r.dynamic === 'undefined') item.problems.push(`${prefix} 缺少 dynamic 字段,必须明确 true / false。`);
if (!item.use) item.problems.push(`${prefix} 缺少 use 字段,应标明 analysis-snapshot / runtime-refresh / static 等。`);
if (item.dynamic) report.dynamicCount += 1;
if (item.dynamic && item.requiredForFinal) report.requiredDynamicCount += 1;
if (item.runtimeRefresh) report.runtimeRefreshCount += 1;
if (item.dynamic && item.file && !/analysis-snapshot|runtime-refresh/i.test(item.use)) {
item.warnings.push(`${prefix} 是动态资源,但 use 未标明 analysis-snapshot 或 runtime-refresh。`);
}
if (item.dynamic && item.requiredForFinal) {
if (!item.runtimeRefresh) item.problems.push(`${prefix} 是影响最终参数生成的动态资源,但 runtimeRefresh 未设置为 true。`);
if (!item.refreshEntry) item.problems.push(`${prefix} 是影响最终参数生成的动态资源,但缺少 refreshEntry。`);
if (args.requireRuntimeRefresh && item.refreshEntry) {
const refreshPath = firstExisting(resolveCasePath(caseDir, item.refreshEntry));
if (!refreshPath) item.problems.push(`${prefix} 的 refreshEntry 不存在:${item.refreshEntry}。`);
}
}
if (item.dynamic && item.file) {
const resourcePath = firstExisting(resolveCasePath(caseDir, item.file));
const resourceHash = resourcePath ? sha256File(resourcePath) : item.sha256;
const resourceBase = path.basename(item.file).toLowerCase();
for (const file of resultFiles) {
const rr = rel(caseDir, file);
const rrLower = rr.toLowerCase();
const sameHash = resourceHash && resultHashes.get(file) === resourceHash;
const snapshotPath = /(^|\/)result\/(?:.*\/)?(?:js\/)?snapshots\//i.test(rrLower) || /(^|\/)snapshots\//i.test(rrLower);
if (sameHash) item.problems.push(`${prefix} 的动态快照内容疑似被复制到最终产物:${rr}。`);
else if (snapshotPath) item.problems.push(`最终产物包含 snapshots 路径,不应交付动态快照:${rr}。`);
else if (resourceBase && path.basename(file).toLowerCase() === resourceBase && /snapshot|challenge|dynamic/i.test(resourceBase)) {
item.warnings.push(`${prefix} 的文件名与 result/ 中 ${rr} 相同,请确认不是复制动态快照。`);
}
}
}
for (const p of item.problems) problems.push(p);
for (const w of item.warnings) warnings.push(w);
}
if (args.requireRuntimeRefresh && report.requiredDynamicCount > 0) {
const entries = finalEntryFiles(resultDir);
if (entries.length === 0) {
warnings.push('result/ 中未发现 final.js / final.py;无法检查最终入口是否调用运行时刷新模块。');
} else {
const finalText = entries.map(readTextSafe).join('\n');
const refreshNames = report.resources
.filter(r => r.dynamic && r.requiredForFinal && r.refreshEntry)
.map(r => path.basename(r.refreshEntry).replace(/\.[^.]+$/, ''))
.filter(Boolean);
const hasRefreshSignal = /fetchRuntimeResources|refreshRuntimeResources|loadRuntimeResources|resourceManifest|运行时刷新|动态资源/.test(finalText)
|| refreshNames.some(name => new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(finalText));
if (!hasRefreshSignal) {
problems.push('最终入口未发现运行时刷新调用痕迹;动态 HTML / JS 影响最终参数生成时,final.js / final.py 必须先刷新当前资源再生成参数。');
}
}
}
if (manifest.resources.length === 0) warnings.push('resource-manifest.json 中 resources 为空;如果已下载 HTML / JS,请补充清单。');
if (resultRelList.some(x => /(^|\/)js\/snapshots\//i.test(x) || /(^|\/)snapshots\//i.test(x))) {
problems.push('最终 result/ 中存在 snapshots 目录或文件;动态快照不得进入最终产物。');
}
report.ok = problems.length === 0;
return report;
}
function toMarkdown(report) {
const lines = [];
lines.push('# 动态资源保鲜检查');
lines.push('');
lines.push(`- 检查结果:${report.ok ? '通过' : '未通过'}`);
lines.push(`- case 目录:${report.caseDir}`);
lines.push(`- manifest:${report.manifestExists ? report.manifestPath : '未找到'}`);
lines.push(`- 资源数量:${report.resources.length}`);
lines.push(`- 动态资源数量:${report.dynamicCount}`);
lines.push(`- 影响最终参数生成的动态资源:${report.requiredDynamicCount}`);
lines.push(`- 已声明运行时刷新的资源:${report.runtimeRefreshCount}`);
lines.push('');
if (report.resources.length) {
lines.push('## 资源概览');
lines.push('');
lines.push('| 序号 | 类型 | 动态 | 最终依赖 | 运行时刷新 | use | refreshEntry | URL |');
lines.push('|---|---|---|---|---|---|---|---|');
for (const r of report.resources) {
lines.push(`| ${r.index} | ${r.type || '-'} | ${r.dynamic ? '是' : '否'} | ${r.requiredForFinal ? '是' : '否'} | ${r.runtimeRefresh ? '是' : '否'} | ${r.use || '-'} | ${r.refreshEntry || '-'} | ${String(r.url || '').replace(/\|/g, '%7C')} |`);
}
lines.push('');
}
if (report.problems.length) {
lines.push('## 必须修复');
lines.push('');
for (const p of report.problems) lines.push(`- ${p}`);
lines.push('');
}
if (report.warnings.length) {
lines.push('## 提醒');
lines.push('');
for (const w of report.warnings) lines.push(`- ${w}`);
lines.push('');
}
if (report.ok) {
lines.push('## 结论');
lines.push('');
lines.push('- 未发现动态资源固定化交付问题。');
if (report.requiredDynamicCount > 0) lines.push('- 影响最终参数生成的动态资源已声明运行时刷新方案。');
else lines.push('- 当前 manifest 未声明影响最终参数生成的动态资源。');
}
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const caseDir = path.resolve(args.caseDir || 'case');
const report = inspect(caseDir, args);
if (args.json) console.log(JSON.stringify(report, null, 2));
else console.log(toMarkdown(report));
if (!report.ok) process.exitCode = 1;
}
try {
main();
} catch (err) {
console.error(`动态资源检查失败:${err.message}`);
process.exit(1);
}