b846f04dab
目的:不再用 Werror,改为「推送前 + CI」的显式检查,并且只拦**基线之外的新增**告警, 这样上游新代码的存量不会挡住任何人,本地开发构建也不会因为告警变红。 单一真源(本地与 CI 调同一份实现): - 新增 `scripts/check-rust-warnings.mjs` + `npm run check:rust-warnings`: 用 `cargo check --message-format=json` 收集告警,键 = `路径|符号|lint`(**不含行号**,抗上游行号漂移), 与 `scripts/warning-baseline.json` 比对:新增 → 退出码 1 并逐条打印;存量减少 → 只提示可更新; 更新用 `--profile=<name> --update-baseline`。 - 新增 `scripts/pre-push-rust-warnings.mjs`:读 pre-push 的 ref 行,**只在本次推送触碰 `*.rs`/`Cargo.toml`/`Cargo.lock`** 时才跑(无关推送不受影响);Windows 跑 `agc-windows`+`server-rs`, Linux/macOS 只跑 `server-rs`。 - `.husky/pre-push`:把 ref 行落一份临时文件供两段检查共用,然后调用上面的脚本(原有的 `check:pre-push-master` 保持不变)。 - `.gitea/workflows/project-ci.yml`:新增 `rust-warning-check` job(`runs-on: genarrative-ci`,Linux), 跑 `--profile=server-rs --profile=agc-linux`,复用 Rust lane 的隔离编译缓存,只 `cargo check` 不 build。 - `scripts/warning-baseline.json`:`agc-windows` 14 条(上游 `b04aceb57` 等引入,逐条见 `local://commit-1a86675f4-inventory.md`);`agc-linux` 433 条(从 CI lane 日志抽取去重,**刻意取超集**, 含测试实例产物,首轮 CI 的多余项只显示为「存量减少」不判失败);`server-rs` 0 条。 - 文档:dev 运维「Rust 编译告警门禁」改写为现役口径(目的/命令/基线更新/覆盖分工与缺口/局限), 旧 Werror 段落标注为历史;development-workflow 的门禁句同步改写。 覆盖分工:CI 覆盖 `server-rs` + AGC 壳 **Linux** 档;AGC 壳 **Windows 生产口径**(GTK/ring 无法在 Linux 交叉编译)**只由本地 pre-push 覆盖**。局限:`--no-verify` 可绕过;本机 target 目录污染要先 `cargo clean -p`;CI 不跑 clippy;基线只保证「不新增」。
258 lines
8.7 KiB
JavaScript
258 lines
8.7 KiB
JavaScript
#!/usr/bin/env node
|
||
// Rust 编译告警的「基线感知」检查。
|
||
//
|
||
// 为什么不是 Werror:`deny(warnings)` 把 warning 变成编译错误,等于让上游新代码的存量告警
|
||
// 直接挡住所有人、也会让本地开发构建变红(PR #650 的决策记录里有完整经过)。改成
|
||
// 「本地 pre-push + CI 的显式检查」,并按基线只拦**新增**告警。
|
||
//
|
||
// 单一真源:本地 `.husky/pre-push` 与 CI 的 `warning check` job 调用的是同一份实现(本文件)。
|
||
//
|
||
// 用法:
|
||
// node scripts/check-rust-warnings.mjs # 按当前平台选默认 profile
|
||
// node scripts/check-rust-warnings.mjs --profile=server-rs --profile=agc-windows
|
||
// node scripts/check-rust-warnings.mjs --profile=agc-linux # CI(Linux)用
|
||
// node scripts/check-rust-warnings.mjs --update-baseline # 把当前结果写回基线(人工确认后)
|
||
// node scripts/check-rust-warnings.mjs --json # 机器可读
|
||
//
|
||
// 基线键 = `路径 | 符号 | lint`(**不含行号**,行号只作输出附注),存于
|
||
// `scripts/warning-baseline.json`。这样上游改一行导致的整体漂移不会让基线全部失效。
|
||
//
|
||
// 退出码:0 = 无新增;1 = 有新增(或调用失败)。存量减少只在输出里提示「可更新基线」。
|
||
|
||
import { spawnSync } from 'node:child_process';
|
||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { dirname, join, relative, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const baselinePath = join(repoRoot, 'scripts', 'warning-baseline.json');
|
||
const AGC_MANIFEST = 'apps/ai-game-creator-shell/src-tauri/Cargo.toml';
|
||
const AGC_FEATURES =
|
||
'cocos-editor-execute,unity-editor-execute,godot-editor-execute';
|
||
|
||
// profile = 一档「口径」:同一份代码在不同平台/feature 下的告警集合互不相同,因此基线按 profile 分组。
|
||
const PROFILES = {
|
||
'server-rs': {
|
||
description:
|
||
'server-rs workspace(Linux/Windows 都可跑;CI 与 pre-push 共用)',
|
||
command: [
|
||
'cargo',
|
||
'check',
|
||
'--locked',
|
||
'--workspace',
|
||
'--all-targets',
|
||
'--manifest-path',
|
||
'server-rs/Cargo.toml',
|
||
],
|
||
platforms: ['linux', 'win32', 'darwin'],
|
||
},
|
||
'agc-windows': {
|
||
description:
|
||
'AGC 壳:Windows + 三编辑器 feature 的生产口径(只有 Windows 本机可跑 → 仅 pre-push 覆盖)',
|
||
command: [
|
||
'cargo',
|
||
'check',
|
||
'--locked',
|
||
'--features',
|
||
AGC_FEATURES,
|
||
'--manifest-path',
|
||
AGC_MANIFEST,
|
||
],
|
||
platforms: ['win32'],
|
||
},
|
||
'agc-linux': {
|
||
description:
|
||
'AGC 壳:Linux target + 三编辑器 feature(CI 覆盖;本机 Windows 无法交叉编译 GTK/ring,跑不了)',
|
||
command: [
|
||
'cargo',
|
||
'check',
|
||
'--locked',
|
||
'--features',
|
||
AGC_FEATURES,
|
||
'--manifest-path',
|
||
AGC_MANIFEST,
|
||
],
|
||
platforms: ['linux'],
|
||
},
|
||
};
|
||
|
||
const argv = process.argv.slice(2);
|
||
const requested = argv
|
||
.filter((a) => a.startsWith('--profile='))
|
||
.map((a) => a.slice('--profile='.length));
|
||
const updateBaseline = argv.includes('--update-baseline');
|
||
const asJson = argv.includes('--json');
|
||
const unknown = argv.filter(
|
||
(a) =>
|
||
a.startsWith('--') &&
|
||
!a.startsWith('--profile=') &&
|
||
a !== '--update-baseline' &&
|
||
a !== '--json',
|
||
);
|
||
if (unknown.length > 0) {
|
||
console.error(`[rust-warnings] 未知参数:${unknown.join(' ')}`);
|
||
process.exit(2);
|
||
}
|
||
|
||
function defaultProfiles() {
|
||
const names = requested.length > 0 ? requested : Object.keys(PROFILES);
|
||
return names.filter((name) => {
|
||
if (requested.length > 0) return true;
|
||
return PROFILES[name].platforms.includes(process.platform);
|
||
});
|
||
}
|
||
|
||
function loadBaseline() {
|
||
if (!existsSync(baselinePath)) return { schema: 1, profiles: {} };
|
||
return JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||
}
|
||
|
||
// 从 rustc 诊断里取「符号」:消息里第一个反引号片段(`fn x` / `unused import: \`a::b\`` / `field \`f\``)。
|
||
function symbolOf(message) {
|
||
const match = /`([^`]+)`/u.exec(message);
|
||
return match ? match[1] : message.slice(0, 80);
|
||
}
|
||
|
||
function lintOf(code) {
|
||
return code?.code ?? 'unknown';
|
||
}
|
||
|
||
function normalizePath(file) {
|
||
return relative(repoRoot, resolve(repoRoot, file)).replaceAll('\\', '/');
|
||
}
|
||
|
||
function collectWarnings(profile) {
|
||
const [bin, ...args] = profile.command;
|
||
const result = spawnSync(bin, [...args, '--message-format=json'], {
|
||
cwd: repoRoot,
|
||
encoding: 'utf8',
|
||
maxBuffer: 256 * 1024 * 1024,
|
||
});
|
||
if (result.error) throw result.error;
|
||
const seen = new Map();
|
||
for (const line of (result.stdout ?? '').split('\n')) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed.startsWith('{')) continue;
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(trimmed);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (payload.reason !== 'compiler-message') continue;
|
||
const message = payload.message ?? {};
|
||
if (message.level !== 'warning') continue;
|
||
const text = message.message ?? '';
|
||
// ts-rs 的宏展开提示不是 lint,长期存在且与代码质量无关,不计入门禁。
|
||
if (text.includes('failed to parse serde attribute')) continue;
|
||
if (message.spans?.length && /generated \d+ warnings?/u.test(text))
|
||
continue;
|
||
const primary =
|
||
(message.spans ?? []).find((span) => span.is_primary) ??
|
||
message.spans?.[0];
|
||
if (!primary) continue;
|
||
const key = {
|
||
path: normalizePath(primary.file_name),
|
||
symbol: symbolOf(text),
|
||
lint: lintOf(message.code),
|
||
};
|
||
const id = `${key.path}|${key.symbol}|${key.lint}`;
|
||
if (!seen.has(id))
|
||
seen.set(id, { ...key, line: primary.line_start, message: text });
|
||
}
|
||
// 非 JSON 输出里的硬错误(编译不过)也算失败。
|
||
const stderr = result.stderr ?? '';
|
||
return { warnings: [...seen.values()], exitCode: result.status, stderr };
|
||
}
|
||
|
||
function keyOf(entry) {
|
||
return `${entry.path}|${entry.symbol}|${entry.lint}`;
|
||
}
|
||
|
||
const baseline = loadBaseline();
|
||
const report = {};
|
||
let hadNew = false;
|
||
let hadFailure = false;
|
||
|
||
for (const name of defaultProfiles()) {
|
||
const profile = PROFILES[name];
|
||
if (!profile) {
|
||
console.error(
|
||
`[rust-warnings] 未知 profile:${name}(可用:${Object.keys(PROFILES).join(', ')})`,
|
||
);
|
||
process.exit(2);
|
||
}
|
||
const { warnings, exitCode, stderr } = collectWarnings(profile);
|
||
const known = new Map(
|
||
(baseline.profiles[name]?.warnings ?? []).map((entry) => [
|
||
keyOf(entry),
|
||
entry,
|
||
]),
|
||
);
|
||
const fresh = warnings.filter((entry) => !known.has(keyOf(entry)));
|
||
const gone = [...known.values()].filter(
|
||
(entry) => !warnings.some((w) => keyOf(w) === keyOf(entry)),
|
||
);
|
||
report[name] = {
|
||
description: profile.description,
|
||
total: warnings.length,
|
||
new: fresh,
|
||
gone,
|
||
};
|
||
if (fresh.length > 0) hadNew = true;
|
||
if (exitCode !== 0 && warnings.length === 0) hadFailure = true;
|
||
if (updateBaseline) {
|
||
baseline.profiles[name] = {
|
||
description: profile.description,
|
||
command: profile.command.join(' '),
|
||
warnings: warnings
|
||
.map(({ path, symbol, lint }) => ({ path, symbol, lint }))
|
||
.sort((a, b) => keyOf(a).localeCompare(keyOf(b))),
|
||
};
|
||
}
|
||
if (!asJson) {
|
||
console.log(`=== ${name}:${profile.description}`);
|
||
console.log(` 命令:${profile.command.join(' ')}`);
|
||
console.log(
|
||
` 当前告警 ${warnings.length} 条 / 基线 ${known.size} 条 → 新增 ${fresh.length},存量减少 ${gone.length}`,
|
||
);
|
||
for (const entry of fresh) {
|
||
console.log(
|
||
` [新增] ${entry.path}:${entry.line} | ${entry.lint} | ${entry.symbol}`,
|
||
);
|
||
console.log(` ${entry.message}`);
|
||
}
|
||
for (const entry of gone)
|
||
console.log(
|
||
` [已消除·可更新基线] ${entry.path} | ${entry.lint} | ${entry.symbol}`,
|
||
);
|
||
if (exitCode !== 0 && warnings.length === 0) {
|
||
console.error(
|
||
` [失败] cargo 退出码 ${exitCode}(编译不过,先修编译)`,
|
||
);
|
||
console.error(stderr.split('\n').slice(-20).join('\n'));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (updateBaseline) {
|
||
writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8');
|
||
if (!asJson)
|
||
console.log(`\n[rust-warnings] 基线已更新:scripts/warning-baseline.json`);
|
||
}
|
||
|
||
if (asJson) console.log(JSON.stringify(report, null, 2));
|
||
if (hadFailure) process.exit(1);
|
||
if (hadNew && !updateBaseline) {
|
||
if (!asJson) {
|
||
console.log(
|
||
'\n[rust-warnings] 有基线之外的新告警:请先修掉;确实是「存量的一部分」时,用',
|
||
);
|
||
console.log(
|
||
' node scripts/check-rust-warnings.mjs --profile=<name> --update-baseline 入册并提交基线。',
|
||
);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
if (!asJson) console.log('\n[rust-warnings] OK(无新增告警)');
|