🔧 chore: 增加生产资源脱链检查并完善质量门禁

新增 check-asset-links,接入 quality-check 与 pre-push;修正 resource-hints 中已失效的 Webpack 产物路径。
This commit is contained in:
Abner
2026-07-17 20:48:11 +08:00
parent 9340bcc132
commit 5f78b2e45b
5 changed files with 181 additions and 10 deletions

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* 生产静态资源脱链检查
* 扫描 HTML 中的本地 script/link/href/src确认文件在仓库或 dist 中存在。
* 禁止把不存在的路径发布到生产。
*/
'use strict';
const fs = require('fs');
const path = require('path');
const projectRoot = path.join(__dirname, '..');
const htmlRoots = [
path.join(projectRoot, 'index.html'),
path.join(projectRoot, 'src', 'giffgaff', 'giffgaff_modular.html'),
path.join(projectRoot, 'src', 'simyo', 'simyo_modular.html')
];
const ATTR_RE = /\b(?:src|href)=["']([^"']+)["']/gi;
const SKIP_PREFIXES = [
'http://',
'https://',
'//',
'data:',
'mailto:',
'tel:',
'javascript:',
'#',
'blob:'
];
function shouldSkip(url) {
return SKIP_PREFIXES.some((p) => url.startsWith(p));
}
// Netlify redirects 中的“软路由”(无静态文件,但生产可访问)
const KNOWN_ROUTES = new Set(['/giffgaff', '/simyo']);
function looksLikeStaticAsset(cleanPath) {
// 有扩展名的视为静态资源;无扩展名多为路由入口
const base = path.basename(cleanPath);
return base.includes('.');
}
function resolveLocal(url) {
// 去掉 query/hash
const clean = url.split('?')[0].split('#')[0];
if (!clean || clean.endsWith('/')) return null;
if (KNOWN_ROUTES.has(clean)) return null;
if (!looksLikeStaticAsset(clean)) return null;
// 仅检查站点内绝对路径
if (clean.startsWith('/')) {
const fromRoot = path.join(projectRoot, clean.slice(1));
const fromDist = path.join(projectRoot, 'dist', clean.slice(1));
return { clean, candidates: [fromRoot, fromDist] };
}
return null;
}
function collectUrls(html) {
const urls = new Set();
let match;
while ((match = ATTR_RE.exec(html)) !== null) {
urls.add(match[1]);
}
return [...urls];
}
function main() {
const missing = [];
let checked = 0;
for (const htmlPath of htmlRoots) {
if (!fs.existsSync(htmlPath)) {
missing.push({ file: htmlPath, url: '(file missing)', reason: 'HTML 入口不存在' });
continue;
}
const html = fs.readFileSync(htmlPath, 'utf8');
const urls = collectUrls(html);
const relHtml = path.relative(projectRoot, htmlPath);
for (const url of urls) {
if (shouldSkip(url)) continue;
const resolved = resolveLocal(url);
if (!resolved) continue;
checked++;
const exists = resolved.candidates.some((p) => fs.existsSync(p));
if (!exists) {
missing.push({
file: relHtml,
url: resolved.clean,
reason: '本地文件不存在(源码与 dist 均未找到)'
});
}
}
}
// 额外:构建产物中禁止残留 Webpack 路径提示(若 resource-hints 被挂载)
const forbiddenHints = [
'/dist/js/main.js',
'/dist/js/vendors.js',
'/dist/css/design-system.css'
];
const resourceHintsPath = path.join(projectRoot, 'src', 'js', 'modules', 'resource-hints.js');
if (fs.existsSync(resourceHintsPath)) {
const content = fs.readFileSync(resourceHintsPath, 'utf8');
for (const bad of forbiddenHints) {
if (content.includes(bad)) {
missing.push({
file: 'src/js/modules/resource-hints.js',
url: bad,
reason: '遗留 Webpack 产物路径,生产构建不会生成该文件'
});
}
}
}
console.log(`[check-asset-links] 已检查本地资源引用: ${checked}`);
if (missing.length === 0) {
console.log('[check-asset-links] ✅ 无脱链');
process.exit(0);
}
console.error('[check-asset-links] ❌ 发现脱链/无效路径:');
missing.forEach((m) => {
console.error(` - ${m.file}: ${m.url} (${m.reason})`);
});
process.exit(1);
}
main();

View File

@@ -103,6 +103,11 @@ function buildSteps(mode) {
{
name: 'pre-commit 检查',
args: ['run', 'precommit:check']
},
{
// 生产 HTML 本地资源脱链门禁(不改运行时,仅静态校验)
name: '生产资源脱链检查',
args: ['run', 'check:links']
}
];

View File

@@ -13,21 +13,30 @@ const projectRoot = path.join(__dirname, '..');
// 检查项配置
const checks = {
// 1. 语法检查
// 1. 语法检查对齐当前生产路径build-static + Netlify Functions
syntaxCheck: {
name: '语法检查',
files: [
'server.js',
'webpack.config.js',
'scripts/build-static.js',
'scripts/transpile-dist-js.js',
'scripts/check-asset-links.js',
'src/js/middleware/validation.js',
'netlify/functions/_shared/middleware.js',
'netlify/functions/_shared/cors.js',
'netlify/functions/_shared/rate-limiter.js',
'netlify/functions/health.js',
'netlify/functions/public-config.js',
'netlify/functions/notifications.js',
'netlify/functions/giffgaff-graphql.js',
'netlify/functions/giffgaff-mfa-challenge.js',
'netlify/functions/giffgaff-mfa-validation.js',
'netlify/functions/giffgaff-sms-activate.js',
'netlify/functions/auto-activate-esim.js',
'netlify/functions/giffgaff-token-exchange.js',
'netlify/functions/verify-cookie.js'
'netlify/functions/verify-cookie.js',
'netlify/edge-functions/bff-proxy.js',
'netlify/edge-functions/markdown-negotiation.js'
]
},
@@ -153,7 +162,7 @@ function runChecks() {
BuildLogger.title('代码质量全面检查');
// 1. 语法检查
BuildLogger.step(1, 4, checks.syntaxCheck.name);
BuildLogger.step(1, 5, checks.syntaxCheck.name);
let syntaxPassed = 0;
checks.syntaxCheck.files.forEach(file => {
totalChecks++;
@@ -169,7 +178,7 @@ function runChecks() {
BuildLogger.progress(`${syntaxPassed}/${checks.syntaxCheck.files.length} 文件通过语法检查\n`);
// 2. 环境变量检查
BuildLogger.step(2, 4, checks.envVarCheck.name);
BuildLogger.step(2, 5, checks.envVarCheck.name);
totalChecks++;
const envIssues = checkEnvExample();
if (envIssues.length === 0) {
@@ -182,7 +191,7 @@ function runChecks() {
}
// 3. 依赖检查
BuildLogger.step(3, 4, checks.dependencyCheck.name);
BuildLogger.step(3, 5, checks.dependencyCheck.name);
totalChecks++;
const depIssues = checkPackageJson();
if (depIssues.length === 0) {
@@ -195,7 +204,7 @@ function runChecks() {
}
// 4. 安全配置检查
BuildLogger.step(4, 4, checks.securityCheck.name);
BuildLogger.step(4, 5, checks.securityCheck.name);
// 检查弱密钥(排除安全检查代码中的引用)
totalChecks++;
@@ -213,6 +222,24 @@ function runChecks() {
failedChecks++;
}
// 5. 生产资源脱链检查(禁止发布不存在的静态路径)
BuildLogger.step(5, 5, '生产资源脱链检查');
totalChecks++;
try {
execSync('node scripts/check-asset-links.js', {
cwd: projectRoot,
stdio: 'pipe',
encoding: 'utf8'
});
BuildLogger.check(true, 'HTML 本地资源引用完整');
passedChecks++;
} catch (error) {
BuildLogger.check(false, '发现脱链或无效路径');
const output = `${error.stdout || ''}${error.stderr || ''}${error.message || ''}`;
output.split('\n').filter(Boolean).forEach((line) => BuildLogger.error(` ${line}`));
failedChecks++;
}
// 统计报告
BuildLogger.title('\n检查结果汇总');
BuildLogger.log(`总检查项: ${totalChecks}`);