Files
Genarrative/scripts/check-rust-warnings.mjs
T
suzmii 8e2b81007a fix(scripts): 让 Rust 告警取数确定性(挂进 npm run lint;clean 本 profile 包 + fresh 完整性断言 + 精确包集 + first-party 过滤)
接法:挂进 npm run lint(CI 的 Repository checks 与推 master 时的 pre-push 共用那条命令),不新增 job/hook;此前自造的 pre-push 触发接线(.husky/pre-push 改动、scripts/pre-push-rust-warnings.mjs、额外 CI job)已撤。
确定性:profile 专属 target 目录 + 对本 profile first-party 包逐个 cargo clean -p <包名> + 取数后 fresh 完整性断言(每个包必须出现 compiler-artifact 且 fresh=false,否则 exit 1 报测量不完整);只收 first-party 路径前缀下的告警。
修掉两个口径 bug:包集把 AGC 壳下的 vendor/ 路径依赖误算成自己(改精确目录判定);normalizeId 只取 id 的 # 片段导致server-rs 27 个同版本成员碰撞(断言永远为假)。
否决“整体清空 target 目录”:实测 AGC 壳清空后需重建 tauri/webkit 全链依赖 15-25 分钟。
实测:A 干净 agc-windows 1/1·14/14 exit 0、server-rs 27/27·0/0 exit 0;B 播种 unused import → agc-windows 15 条/新增 1、server-rs 1 条/新增 1,均 exit 1 并逐条打印;C 撤销 → 均 exit 0;负向测试(GENARRATIVE_WARNCHECK_SKIP_CLEAN 漏清一个成员)→ 26/27 + 测量不完整 → exit 1。
基线:agc-windows 14 条 / server-rs 0 条 / agc-linux 首采(首次 CI 只采集不判失败,需一次 --update-baseline)。
文档:dev 运维「Rust 编译告警门禁」同步接法、首采、开关、否决清空的原因与局限。
2026-10-07 19:57:17 +08:00

469 lines
18 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 编译告警的「基线感知」检查:本地 pre-push 与 CI 的 warning check 共用这一份实现。
//
// 为什么不是 Werror:把 warning 变成编译错误会让上游新代码的存量告警直接挡住所有人,
// 也会让本地开发构建变红(见 PR #650 的决策记录)。改成「推送前 + CI」的显式检查,只拦新增。
//
// ## 测量口径(先定死)
// - **只收本 profile 的 first-party 告警**(按源码路径前缀):
// `agc-*` → `apps/ai-game-creator-shell/src-tauri/**`;`server-rs` → `server-rs/**`。
// 这样 path 依赖(shared-contracts / platform-llm …)的告警不会串进 AGC profile
// (它们是 server-rs profile 的职责),也解释了「同一棵树 14 条 vs 33 条」的差。
// - 归属判定用 `compiler-message` 的主 span 的 `file_name`(相对仓库根规范化)做前缀匹配。
//
// ## 确定性来源(两层,缺一不可)
// 1. **profile 专属 target 目录**:默认 `<os.tmpdir()>/genarrative-warncheck/<profile>`,
// 由脚本自己设 CARGO_TARGET_DIR(**不继承外部**,避免「清了 A 目录、编了 B 目录」);
// 可用 `GENARRATIVE_WARNCHECK_TARGET_DIR` 显式覆盖(CI 想复用缓存时用)。
// 2. **同名 env 下 `cargo clean -p` 精确清理本 profile 的 first-party 包**:包集用
// `cargo metadata` 的 `id` **精确集合**(注意:server-rs 成员的 id 形如 `path+file:///…#0.1.0`
// —— **不含包名**;AGC 是 `…#genarrative-ai-game-creator-shell@0.1.67`),clean 用 `name@version`。
// 3. **断言**:本 profile 的每个包都必须出现 `compiler-artifact` 且 `fresh === false`;
// 否则 exit 1 并提示「测量不完整」——**绝不给「新增 0」的假答案**。
// 根因备忘:cargo 只对真正重编的 crate 打印告警,热目录下未变化 crate 的存量告警不会重现。
//
// 用法:
// 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 --profile=server-rs --update-baseline
// node scripts/check-rust-warnings.mjs --json
//
// 基线键 = `路径 | 符号 | lint`(不含行号)。退出码:0=有效且无新增;1=有新增/测量不完整/编译失败;2=参数错。
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } 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';
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',
],
manifest: 'server-rs/Cargo.toml',
firstParty: ['server-rs'],
platforms: ['linux', 'win32', 'darwin'],
},
'agc-windows': {
description:
'AGC 壳:Windows + 三编辑器 feature 的生产口径(只有 Windows 本机可跑 → 仅 pre-push 覆盖)',
command: [
'cargo',
'check',
'--locked',
'--features',
AGC_FEATURES,
'--manifest-path',
AGC_MANIFEST,
],
manifest: AGC_MANIFEST,
firstParty: ['apps/ai-game-creator-shell/src-tauri'],
packageDirs: ['apps/ai-game-creator-shell/src-tauri'],
platforms: ['win32'],
},
'agc-linux': {
description:
'AGC 壳:Linux target + 三编辑器 feature(CI 覆盖;本机 Windows 无法交叉编译 GTK/ring)',
command: [
'cargo',
'check',
'--locked',
'--features',
AGC_FEATURES,
'--manifest-path',
AGC_MANIFEST,
],
manifest: AGC_MANIFEST,
firstParty: ['apps/ai-game-creator-shell/src-tauri'],
packageDirs: ['apps/ai-game-creator-shell/src-tauri'],
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);
}
if (process.env.GENARRATIVE_SKIP_RUST_WARNINGS === '1') {
console.log(
'[rust-warnings] GENARRATIVE_SKIP_RUST_WARNINGS=1:本次跳过 Rust 告警检查(仅建议本地临时使用)。',
);
process.exit(0);
}
function targetDirFor(name) {
// 显式覆盖:CI 可以把这里指向复用的编译缓存目录(值就是最终使用的目录,不再追加 profile 名)。
if (process.env.GENARRATIVE_WARNCHECK_TARGET_DIR) {
const dir = process.env.GENARRATIVE_WARNCHECK_TARGET_DIR;
mkdirSync(dir, { recursive: true });
return dir;
}
// 默认:profile 专属目录(同一个目录被后续运行复用,只有首次要全量编译依赖)。
const dir = join(tmpdir(), 'genarrative-warncheck', name);
mkdirSync(dir, { recursive: true });
return dir;
}
function cargo(args, env) {
return spawnSync('cargo', args, {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
env,
});
}
function packageIds(profile) {
const meta = cargo(
[
'metadata',
'--format-version',
'1',
'--no-deps',
'--manifest-path',
profile.manifest,
],
{
...process.env,
CARGO_TARGET_DIR: targetDirFor(profile.name),
},
);
if (meta.status !== 0) return [];
try {
const parsed = JSON.parse(meta.stdout);
const members = new Set(parsed.workspace_members ?? []);
const root = repoRoot.replaceAll('\\', '/');
return (parsed.packages ?? [])
.filter((pkg) => {
if (!members.has(pkg.id)) return false;
const manifest = String(pkg.manifest_path).replaceAll('\\', '/');
if (!manifest.startsWith(root)) return false;
const dir = manifest.slice(0, manifest.lastIndexOf('/'));
// 只把**本 profile 自己的**包纳入「必须重编」的断言。
// 注意:AGC 壳的目录下还有 `vendor/**` 的 path 依赖(codex-patch-parser 等),
// 它们不是本 profile 的包,不要求重编(它们的告警也被 first-party 前缀过滤掉)。
if (profile.packageDirs)
return profile.packageDirs.some((dir_) => dir === `${root}/${dir_}`);
return profile.firstParty.some(
(prefix) =>
dir === `${root}/${prefix}` || dir.startsWith(`${root}/${prefix}/`),
);
})
.map((pkg) => ({
id: pkg.id,
name: pkg.name,
spec: `${pkg.name}@${pkg.version}`,
manifestPath: pkg.manifest_path,
}));
} catch {
return [];
}
}
function normalizeId(id) {
const text = String(id ?? '');
// 旧形态 `<name> <version> (path+file:///…)`:取括号里的 path+… 部分;
// 其余(`path+file:///…#<fragment>`)**原样使用**。
// 曾经踩过的坑:只取 `#` 之后的片段会碰撞——server-rs 的 27 个成员版本全是 0.1.0,
// 归一化后都变成 "0.1.0",于是「每个包都必须重编」的断言永远为假(观察到「重编 1/27 却 exit 0」)。
const match = /\((?<path>path\+[^)]*)\)$/u.exec(text);
return match ? match.groups.path : text;
}
function isFirstParty(profile, relativePath) {
const posix = relativePath.split(sep).join('/');
return profile.firstParty.some(
(prefix) => posix === prefix || posix.startsWith(`${prefix}/`),
);
}
function collectWarnings(profile, packages, env) {
const [bin, ...args] = profile.command;
// 确定性由两步保证:① 本 profile 的 first-party 包逐个 `cargo clean -p <包名>`(保留依赖缓存);
// ② 取数后用**条数对照**自证(见下方 incomplete 判定)。整体清空 target 目录的方案已被否决:
// 实测 AGC 壳清空后要重建 tauri/webkit 全链依赖(15-25 分钟),代价不可接受。
const cleanArgs = ['clean', '--manifest-path', profile.manifest];
// 仅用于验证「完整性断言真的会失败」:GENARRATIVE_WARNCHECK_SKIP_CLEAN=<包名> 时故意漏清一个包。
const skipClean = process.env.GENARRATIVE_WARNCHECK_SKIP_CLEAN;
for (const pkg of packages) {
if (skipClean && pkg.name === skipClean) continue;
cleanArgs.push('-p', pkg.name);
}
const clean = cargo(cleanArgs, env);
const result = spawnSync(bin, [...args, '--message-format=json'], {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
env,
});
if (result.error) throw result.error;
const wanted = new Map(packages.map((pkg) => [normalizeId(pkg.id), pkg.id]));
const rebuilt = new Set();
const seen = new Map();
const artifacts = new Map();
let messageTotal = 0;
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') messageTotal += 1;
if (payload.reason === 'compiler-artifact') {
const id = normalizeId(payload.package_id);
if (wanted.has(id)) {
artifacts.set(id, payload.fresh === false ? 'rebuilt' : 'fresh');
if (payload.fresh === false) rebuilt.add(id);
}
continue;
}
if (payload.reason !== 'compiler-message') continue;
const message = payload.message ?? {};
if (message.level !== 'warning') continue;
const text = message.message ?? '';
if (text.includes('failed to parse serde attribute')) continue;
const primary =
(message.spans ?? []).find((span) => span.is_primary) ??
message.spans?.[0];
if (!primary) continue;
// cargo 的 span 路径对 workspace 成员是**相对该成员根目录**的(如 src/main.rs),
// 所以先按 manifest 所在目录拼,再回退到仓库根。
const packageDir = dirname(resolve(repoRoot, profile.manifest));
const candidates = [
resolve(packageDir, primary.file_name),
resolve(repoRoot, primary.file_name),
];
const absolute =
candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
const path = relative(repoRoot, absolute).replaceAll('\\', '/');
if (!isFirstParty(profile, path)) continue;
const key = {
path,
symbol: /`([^`]+)`/u.exec(text)?.[1] ?? text.slice(0, 80),
lint: message.code?.code ?? 'unknown',
};
const id = `${key.path}|${key.symbol}|${key.lint}`;
if (!seen.has(id))
seen.set(id, { ...key, line: primary.line_start, message: text });
}
return {
warnings: [...seen.values()],
rebuilt: [...rebuilt],
artifacts,
exitCode: result.status,
stderr: result.stderr ?? '',
cleanCmd: cleanArgs,
cleanExit: clean.status,
cleanOut: `${clean.stdout ?? ''}${clean.stderr ?? ''}`.trim(),
artifactDump: [...artifacts.entries()].map(
([id, state]) => `${id} → ${state}`,
),
messageCount: messageTotal,
};
}
function keyOf(entry) {
return `${entry.path}|${entry.symbol}|${entry.lint}`;
}
function defaultProfiles() {
if (requested.length > 0) return requested;
return Object.keys(PROFILES).filter((name) =>
PROFILES[name].platforms.includes(process.platform),
);
}
const baseline = existsSync(baselinePath)
? JSON.parse(readFileSync(baselinePath, 'utf8'))
: { schema: 1, profiles: {} };
const report = {};
let hadNew = false;
let hadIncomplete = false;
for (const name of defaultProfiles()) {
const profile = { ...PROFILES[name], name };
if (!PROFILES[name]) {
console.error(
`[rust-warnings] 未知 profile:${name}(可用:${Object.keys(PROFILES).join(', ')})`,
);
process.exit(2);
}
const dir = targetDirFor(name);
const env = { ...process.env, CARGO_TARGET_DIR: dir };
const packages = packageIds(profile);
const {
warnings,
rebuilt,
artifacts,
exitCode,
stderr,
cleanCmd,
cleanExit,
cleanOut,
artifactDump,
messageCount,
} = collectWarnings(profile, packages, env);
const known = new Map(
(baseline.profiles[name]?.warnings ?? []).map((entry) => [
keyOf(entry),
entry,
]),
);
// 还没有基线条目的 profile = **首次采集**:只打印清单、不判失败(例如 agc-linux 只能由 CI 首跑采集)。
const collectOnly = !baseline.profiles[name];
const fresh = collectOnly
? []
: warnings.filter((entry) => !known.has(keyOf(entry)));
const gone = [...known.values()].filter(
(entry) => !warnings.some((w) => keyOf(w) === keyOf(entry)),
);
// **完整性断言 = fresh 断言**:本 profile 的每个 first-party 包都必须在本轮被 clean 后真的重编
// (出现 compiler-artifact 且 fresh=false)。否则测量不完整(热目录下未重编的 crate 不会重现存量
// 告警,会给出「新增 0」的假答案)⇒ exit 1。注意:**不**用「条数少于基线」当不完整——真实的清理
// 会让条数变少,那是正常结果(走「存量减少」提示)。
const incomplete =
packages.length === 0 ||
packages.some((pkg) => !rebuilt.includes(normalizeId(pkg.id)));
report[name] = {
description: profile.description,
targetDir: dir,
rebuilt: `${rebuilt.size}/${packages.length}`,
artifacts: [...artifacts.entries()].map(([id, state]) => `${id}:${state}`),
total: warnings.length,
new: fresh,
gone,
incomplete,
};
if (fresh.length > 0) hadNew = true;
if (incomplete || (exitCode !== 0 && warnings.length === 0))
hadIncomplete = true;
if (updateBaseline) {
baseline.profiles[name] = {
description: profile.description,
command: profile.command.join(' '),
targetDirPolicy:
'profile 专属目录(默认 <tmp>/genarrative-warncheck/<profile>;可用 GENARRATIVE_WARNCHECK_TARGET_DIR 覆盖)',
scope: `只收 first-party 告警:${profile.firstParty.join(', ')}`,
baselineNote:
name === 'agc-linux'
? '本机(Windows)无法编译 Linux 档,条目留空,由 CI 首跑采集后再 --update-baseline 入册。'
: undefined,
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(` target 目录:${dir}`);
console.log(
` 命令:${cleanArgsText(profile)} 然后 ${profile.command.join(' ')}`,
);
if (process.env.GENARRATIVE_WARNCHECK_DEBUG) {
const dump = [` [dump] 包集 ${packages.length} 个:`];
for (const pkg of packages)
dump.push(
` name=${pkg.spec} id=${pkg.id}`,
` manifest=${pkg.manifestPath}`,
);
dump.push(` [dump] clean:${cleanCmd.join(' ')} → exit ${cleanExit}`);
if (cleanOut)
dump.push(...cleanOut.split('\n').map((line) => ` ${line}`));
dump.push(' [dump] artifact(package_id → fresh):');
for (const line of artifactDump) dump.push(` ${line}`);
dump.push(
` [dump] compiler-message 总数 ${messageCount} 条(其中 first-party 告警 ${warnings.length} 条)`,
);
const missing = packages
.filter((pkg) => !rebuilt.includes(normalizeId(pkg.id)))
.map((pkg) => `${pkg.name} (${pkg.id})`);
dump.push(
` [dump] 未重编的成员 ${missing.length} 个:${missing.join(' | ') || '(none)'}`,
);
console.log(dump.join('\n'));
}
console.log(
` 本次重编 ${rebuilt.length}/${packages.length} 个包 · 当前 first-party 告警 ${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 (incomplete) {
console.error(
` [失败] 测量不完整:${packages.length - rebuilt.length} 个本 profile 的包本次未被重编,读到的告警数不可信。` +
`\n 重跑一次;仍失败就删除 target 目录(${dir})后重试。`,
);
} else if (exitCode !== 0 && warnings.length === 0) {
console.error(
` [失败] cargo 退出码 ${exitCode}(编译不过,先修编译)\n${stderr.split('\n').slice(-15).join('\n')}`,
);
}
}
}
function cleanArgsText(profile) {
return `cargo clean -p <本 profile 的 ${profile.firstParty.join(' / ')} 包>`;
}
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 (hadIncomplete) 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(无新增告警)');