Files
Genarrative/scripts/check-rust-warnings.mjs
T
suzmii a5138d2eca
Project CI / Backend tests (pull_request) Failing after 37s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 7m46s
Project CI / Repository checks (pull_request) Failing after 28s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m14s
Project CI / Native shell tests (pull_request) Successful in 11m10s
Project CI / Frontend tests (pull_request) Successful in 4m3s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m15s
fix(scripts): 补上首采模式逐条打印(上一次编辑未覆盖到)
prettier 把 for 循环重排成无花括号形式,导致上一次插入点没匹配上:现在真正逐条打印 [首采] 路径:行 | lint | 符号 + 条数与入册命令;--json 的 report[name].collected 已带上完整条目。实测:临时移除 agc-windows 基线条目 → 首采打印 14 行、exit 0;还原后 14/14 exit 0。
2026-10-07 20:25:21 +08:00

548 lines
21 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' &&
a !== '--only-if-changed' &&
!a.startsWith('--refs-file='),
);
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);
}
// `--only-if-changed`:pre-push 钩子对所有分支一视同仁地调用本脚本,但只在本次推送**真的触碰 Rust 代码**
// (*.rs / Cargo.toml / Cargo.lock)时才执行检查——按内容触发,不看分支。refs 从 stdin 读(pre-push 约定:
// `local_ref local_sha remote_ref remote_sha`)。
if (argv.includes('--only-if-changed')) {
const refsFileArg = argv.find((a) => a.startsWith('--refs-file='));
// 钩子把 pre-push 的 refs 落成文件传进来,从而把 stdin 原样留给最后那行原有的钩子命令。
const refsText = refsFileArg
? readFileSync(refsFileArg.slice('--refs-file='.length), 'utf8')
: readFileSync(0, 'utf8');
const gitOut = (...args) => {
const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
return result.status === 0 ? (result.stdout ?? '').trim() : '';
};
const ZERO_SHA = /^0+$/u;
const touched = new Set();
for (const line of refsText.split('\n')) {
const [localRef, localSha, , remoteSha] = line.trim().split(/\s+/u);
if (!localRef || !localSha || ZERO_SHA.test(localSha)) continue;
let base = ZERO_SHA.test(remoteSha ?? '') ? '' : remoteSha;
if (!base) {
base =
gitOut('merge-base', 'origin/master', localSha) ||
gitOut('rev-parse', `${localSha}^`);
}
if (!base) continue;
for (const path of gitOut(
'diff',
'--name-only',
`${base}..${localSha}`,
).split('\n')) {
if (path) touched.add(path);
}
}
const rustPaths = [...touched].filter((path) =>
/(\.rs$|Cargo\.toml$|Cargo\.lock$)/u.test(path),
);
if (rustPaths.length === 0) {
console.log(
`[rust-warnings] 本次推送未触碰 Rust 代码(共 ${touched.size} 个文件),跳过检查。`,
);
process.exit(0);
}
console.log(
`[rust-warnings] 本次推送触碰 Rust 文件 ${rustPaths.length} 个,开始检查。`,
);
}
let warmTargetDir;
function workspaceTargetDir() {
if (warmTargetDir) return warmTargetDir;
// 不带 CARGO_TARGET_DIR 跑 metadata,拿到 cargo 实际会用的 target 目录。
const meta = cargo(['metadata', '--format-version', '1', '--no-deps'], {
...process.env,
});
try {
warmTargetDir = JSON.parse(meta.stdout).target_directory;
} catch {
warmTargetDir = join(tmpdir(), 'genarrative-warncheck');
}
return warmTargetDir;
}
function targetDirFor(_name) {
// 显式覆盖:CI 可以把这里指向复用的编译缓存目录(值就是最终使用的目录)。
if (process.env.GENARRATIVE_WARNCHECK_TARGET_DIR) {
const dir = process.env.GENARRATIVE_WARNCHECK_TARGET_DIR;
mkdirSync(dir, { recursive: true });
return dir;
}
// 默认用**仓库常规 target 目录**(依赖已热):每次只 clean/重编本 profile 的包,秒级到分钟级。
// 代价是会给开发者自己的增量状态带来抖动(可接受)。安全网是 fresh 完整性断言:
// 倘若本 profile 的包没有被重编,脚本会 exit 1 报测量不完整,绝不会静默给出“新增 0”。
const dir = workspaceTargetDir();
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,
collected: collectOnly ? warnings : undefined,
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 (collectOnly) {
// 首采:还没有基线条目 ⇒ 逐条打印清单,首次 CI 日志里就能拿到可入册的键(路径|符号|lint)。
for (const entry of warnings) {
console.log(
` [首采] ${entry.path}:${entry.line} | ${entry.lint} | ${entry.symbol}`,
);
}
console.log(
` [首采] 共 ${warnings.length} 条;入册:node scripts/check-rust-warnings.mjs --profile=${name} --update-baseline`,
);
}
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(无新增告警)');