Files
Genarrative/scripts/check-rust-warnings.mjs
T
suzmii b846f04dab chore: 加 Rust 告警的 pre-push + CI 基线感知检查(单一脚本 + 基线)
目的:不再用 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;基线只保证「不新增」。
2026-10-07 19:22:29 +08:00

258 lines
8.7 KiB
JavaScript
Raw 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
// 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(无新增告警)');