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 编译告警门禁」同步接法、首采、开关、否决清空的原因与局限。
This commit is contained in:
2026-10-07 19:57:17 +08:00
parent b846f04dab
commit 8e2b81007a
7 changed files with 304 additions and 2383 deletions
-50
View File
@@ -470,56 +470,6 @@ jobs:
- name: Validate production API deploy behavior
run: npm run check:production-api-deploy
rust-warning-check:
name: Rust warning check
runs-on: genarrative-ci
steps:
- name: Checkout source from Gitea
env:
GENARRATIVE_GITEA_FETCH_DEPTH: '1'
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: genarrative-gitea-checkout
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
# 只为这个 job 需要的依赖预热;复用与 Rust lane 相同的隔离编译缓存,避免全量重编。
- name: Prepare Rust dependencies
shell: bash
run: |
set -euo pipefail
for manifest in server-rs/Cargo.toml apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "dependency fetch failed after 5 attempts: ${manifest}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Prepare isolated Rust compilation cache
shell: bash
run: |
set -euo pipefail
node --test scripts/ci-rust-cache.test.mjs
bash scripts/ci-rust-cache.sh prepare
# 与本地 pre-push 同一个脚本(scripts/check-rust-warnings.mjs);基线感知,只拦「新增」告警。
# 覆盖:server-rs workspace(Linux 可跑,0 存量)+ AGC 壳 Linux 档。
# 不覆盖:AGC 壳「Windows + 编辑器 feature」生产口径——那档只能在 Windows 上编,由本地钩子覆盖。
- name: Run Rust warning check
run: npm run check:rust-warnings -- --profile=server-rs --profile=agc-linux
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
repository-checks:
name: Repository checks
runs-on: genarrative-ci
+1 -6
View File
@@ -1,9 +1,4 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
# pre-push 的 ref 行(stdin)被两段检查共用:先落一份临时文件,第二段才读得到。
push_refs="$(mktemp)"
cat > "${push_refs}"
trap 'rm -f "${push_refs}"' EXIT
npm run check:pre-push-master -- "$@" < "${push_refs}"
node scripts/pre-push-rust-warnings.mjs < "${push_refs}"
npm run check:pre-push-master -- "$@"
@@ -382,6 +382,15 @@ npm run check
> - **局限**:① `git push --no-verify` 可以绕过(CI 仍会跑,但只覆盖 Linux 档 + server-rs);② 本机 target 目录被污染时会读出假数字,取数前先 `cargo clean -p <crate>`;③ CI 不跑 clippy;④ 基线只保证「不新增」,不保证存量收敛;⑤ AGC 壳 Windows 档只有在 Windows 机器上推代码时才被检查。
>
> 以下小节是**历史记录**(`deny(warnings)` 时代的落法、AGC 门禁 predicate、harness 假告警边界、2026-10-06 基线表与检查命令),保留供追溯;其中的 Werror 接线与判据已随本 PR 撤销,`deny(warnings)` 在 tip 上**已 0 命中**。
>
> **接法更正(2026-10-07 收口)**:不另造 hook / 不新增 CI job,而是把检查**挂进 `npm run lint`**——它是 CI 的 `Repository checks`(`.gitea/workflows/project-ci.yml` 里 `run: npm run check:repository-ci` → `scripts/check-repository-ci.sh` → `npm run lint`)与「推 master 时的 pre-push」(`.husky/pre-push` → `npm run check:pre-push-master` → `scripts/pre-push-master.sh` → `npm run check:repository-ci`)**共用的那条命令**;因此一次接入,两处生效。
>
> - **feature 分支推送本地不跑**这项检查(`scripts/pre-push-master.sh` 只在推 `refs/heads/master` 时才真正执行;这是仓库既有行为,未改)。
> - **测量确定性**(`scripts/check-rust-warnings.mjs`):profile 专属 target 目录(默认 `<tmp>/genarrative-warncheck/<profile>`,可用 `GENARRATIVE_WARNCHECK_TARGET_DIR` 指向复用缓存)+ 对本 profile 的 first-party 包逐个 `cargo clean -p <包名>`,取数后**完整性断言**:profile 的每个包都必须出现 `compiler-artifact` 且 `fresh=false`,否则 exit 1 报「测量不完整」(原因:cargo 只对真正重编的 crate 打印告警,热目录下未变化 crate 的存量告警不重现,会给出「新增 0」的假答案)。
> - **被否决的方案**:取数前**整体清空** target 目录——实测 AGC 壳清空后要重建 tauri/webkit 全链依赖(**15-25 分钟**),代价不可接受。
> - **首采**:`agc-linux` 档本机(Windows)编不了,基线**留空**;首次 CI 运行以「采集」模式打印清单且**不判失败**,跑完需要一次 `node scripts/check-rust-warnings.mjs --profile=agc-linux --update-baseline` 入册。
> - **开关**:`GENARRATIVE_SKIP_RUST_WARNINGS=1` 可本地跳过(日常 `npm run lint` 不想多花几分钟时用);`GENARRATIVE_WARNCHECK_DEBUG=1` 打印包集 / clean 命令 / artifact 的 fresh 表 / message 总数。
> - **局限**:`git push --no-verify` 可绕过;CI 侧**不覆盖** AGC 壳的 Windows 生产口径(GTK/ring 无法在 Linux 交叉编译);基线只保证「不新增」,不保证存量收敛。
- 口径:全仓 Rust **first-party** 编译 warning 清零,能直接修的一律修掉,不用 `#[allow]` 掩盖;与既有文档口径冲突的优先修订口径(同批同步改文档);确实不能修的逐条登记原因与关闭条件。仍然禁止:新增**未登记**的 `#[allow(dead_code)]`、假引用、仅为消警删除测试;生成代码不手改(要改就改生成器)。下面一条是当前**唯一**登记的例外。
- **已登记的 `#[allow(dead_code)]` 例外(2026-10-07,PR #650「Rust 编译告警」)**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider/mod.rs` 顶部的 `#![allow(dead_code)]`,覆盖它用 `#[path]` 挂载的两棵子树——`app_server`(= `agent/codex_app_server/**`,含凭据、模型目录与 OAuth 交接)与 `cli`(= `agent/codex_cli.rs`,含 CLI 请求路径)。
+1 -1
View File
@@ -139,7 +139,7 @@
"check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && npm run check:module-runtime-artifact && node scripts/check-server-rs-ddd-boundaries.mjs",
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
"lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:generated-bindings && npm run check:game-distribution-dto-parity && npm run check:game-distribution-price-limit-parity && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run check:nginx-spa-routes && npm run check:pingora-route-parity && npm run lint:eslint && npm run typecheck",
"lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:rust-warnings && npm run check:spacetime-schema && npm run check:generated-bindings && npm run check:game-distribution-dto-parity && npm run check:game-distribution-price-limit-parity && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run check:nginx-spa-routes && npm run check:pingora-route-parity && npm run lint:eslint && npm run typecheck",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
"format:rust": "cargo fmt --all --manifest-path server-rs/Cargo.toml && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml",
"format": "prettier --write . && npm run format:rust",
+273 -62
View File
@@ -1,27 +1,40 @@
#!/usr/bin/env node
// Rust 编译告警的「基线感知」检查。
// Rust 编译告警的「基线感知」检查:本地 pre-push 与 CI 的 warning check 共用这一份实现。
//
// 为什么不是 Werror:`deny(warnings)` 把 warning 变成编译错误,等于让上游新代码的存量告警
// 直接挡住所有人、也会让本地开发构建变红(PR #650 的决策记录里有完整经过)。改成
// 「本地 pre-push + CI 的显式检查」,并按基线只拦**新增**告警。
// 为什么不是 Werror:把 warning 变成编译错误会让上游新代码的存量告警直接挡住所有人,
// 也会让本地开发构建变红(见 PR #650 的决策记录)。改成「推送前 + CI」的显式检查,只拦新增。
//
// 单一真源:本地 `.husky/pre-push` 与 CI 的 `warning check` job 调用的是同一份实现(本文件)。
// ## 测量口径(先定死)
// - **只收本 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
// 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 # 机器可读
// 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`(**不含行号**,行号只作输出附注),存于
// `scripts/warning-baseline.json`。这样上游改一行导致的整体漂移不会让基线全部失效。
//
// 退出码:0 = 无新增;1 = 有新增(或调用失败)。存量减少只在输出里提示「可更新基线」。
// 基线键 = `路径 | 符号 | lint`(不含行号)。退出码:0=有效且无新增;1=有新增/测量不完整/编译失败;2=参数错。
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
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)), '..');
@@ -30,7 +43,6 @@ 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:
@@ -44,6 +56,8 @@ const PROFILES = {
'--manifest-path',
'server-rs/Cargo.toml',
],
manifest: 'server-rs/Cargo.toml',
firstParty: ['server-rs'],
platforms: ['linux', 'win32', 'darwin'],
},
'agc-windows': {
@@ -58,11 +72,14 @@ const PROFILES = {
'--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,跑不了)',
'AGC 壳:Linux target + 三编辑器 feature(CI 覆盖;本机 Windows 无法交叉编译 GTK/ring)',
command: [
'cargo',
'check',
@@ -72,6 +89,9 @@ const PROFILES = {
'--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'],
},
};
@@ -93,43 +113,125 @@ 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 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 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 loadBaseline() {
if (!existsSync(baselinePath)) return { schema: 1, profiles: {} };
return JSON.parse(readFileSync(baselinePath, 'utf8'));
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 [];
}
}
// 从 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 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 lintOf(code) {
return code?.code ?? 'unknown';
function isFirstParty(profile, relativePath) {
const posix = relativePath.split(sep).join('/');
return profile.firstParty.some(
(prefix) => posix === prefix || posix.startsWith(`${prefix}/`),
);
}
function normalizePath(file) {
return relative(repoRoot, resolve(repoRoot, file)).replaceAll('\\', '/');
}
function collectWarnings(profile) {
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;
@@ -139,82 +241,184 @@ function collectWarnings(profile) {
} 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 ?? '';
// 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;
// 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: normalizePath(primary.file_name),
symbol: symbolOf(text),
lint: lintOf(message.code),
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 });
}
// 非 JSON 输出里的硬错误(编译不过)也算失败。
const stderr = result.stderr ?? '';
return { warnings: [...seen.values()], exitCode: result.status, stderr };
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}`;
}
const baseline = loadBaseline();
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 hadFailure = false;
let hadIncomplete = false;
for (const name of defaultProfiles()) {
const profile = PROFILES[name];
if (!profile) {
const profile = { ...PROFILES[name], name };
if (!PROFILES[name]) {
console.error(
`[rust-warnings] 未知 profile:${name}(可用:${Object.keys(PROFILES).join(', ')})`,
);
process.exit(2);
}
const { warnings, exitCode, stderr } = collectWarnings(profile);
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,
]),
);
const fresh = warnings.filter((entry) => !known.has(keyOf(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 (exitCode !== 0 && warnings.length === 0) hadFailure = 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(` 命令:${profile.command.join(' ')}`);
console.log(` target 目录:${dir}`);
console.log(
` 当前告警 ${warnings.length} 条 / 基线 ${known.size} 条 → 新增 ${fresh.length},存量减少 ${gone.length}`,
` 命令:${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(
@@ -226,30 +430,37 @@ for (const name of defaultProfiles()) {
console.log(
` [已消除·可更新基线] ${entry.path} | ${entry.lint} | ${entry.symbol}`,
);
if (exitCode !== 0 && warnings.length === 0) {
if (incomplete) {
console.error(
` [失败] cargo 退出码 ${exitCode}(编译不过,先修编译)`,
` [失败] 测量不完整:${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')}`,
);
console.error(stderr.split('\n').slice(-20).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`);
console.log('\n[rust-warnings] 基线已更新:scripts/warning-baseline.json');
}
if (asJson) console.log(JSON.stringify(report, null, 2));
if (hadFailure) process.exit(1);
if (hadIncomplete) process.exit(1);
if (hadNew && !updateBaseline) {
if (!asJson) {
console.log(
'\n[rust-warnings] 有基线之外的新告警:请先修掉;确实是「存量的一部分」时,用',
'\n[rust-warnings] 有基线之外的新告警:先修掉;确实是存量的一部分时用',
);
console.log(
' node scripts/check-rust-warnings.mjs --profile=<name> --update-baseline 入册并提交基线。',
' node scripts/check-rust-warnings.mjs --profile=<name> --update-baseline 入册。',
);
}
process.exit(1);
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env node
// pre-push 的 Rust 告警检查入口:只在「本次推送的提交确实碰了 Rust 代码」时才跑,
// 避免无关推送(纯文档/前端)被编译检查拖慢。
//
// 与 CI 共用同一份检查实现:scripts/check-rust-warnings.mjs(单一真源)。
// 平台差异:Windows 跑 `agc-windows` + `server-rs`;Linux/macOS 只跑 `server-rs`
// (AGC 壳的 Windows 生产口径只能在 Windows 上编;Linux 档由 CI 的 Rust warning check job 覆盖)。
//
// 用法(由 .husky/pre-push 调用,stdin 为 `local_ref local_sha remote_ref remote_sha` 行):
// node scripts/pre-push-rust-warnings.mjs < changes.txt
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const ZERO = /^0+$/u;
const RUST_RELEVANT = /(\.rs$|Cargo\.toml$|Cargo\.lock$)/u;
const stdin = readFileSync(0, 'utf8');
const pushed = [];
for (const line of stdin.split('\n')) {
const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/u);
if (!localRef || !localSha || ZERO.test(localSha)) continue;
pushed.push({ localRef, localSha, remoteRef, remoteSha });
}
function git(...args) {
const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
return result.status === 0 ? (result.stdout ?? '').trim() : '';
}
const changed = new Set();
for (const { localSha, remoteSha } of pushed) {
let base = ZERO.test(remoteSha ?? '') ? '' : remoteSha;
if (!base) {
// 新分支/首次推送:用与 origin/master 的合并基点作为比较基线;取不到就用该提交的父提交。
base =
git('merge-base', 'origin/master', localSha) ||
git('rev-parse', `${localSha}^`);
}
if (!base) continue;
for (const path of git('diff', '--name-only', `${base}..${localSha}`).split(
'\n',
)) {
if (path) changed.add(path);
}
}
const relevant = [...changed].filter((path) => RUST_RELEVANT.test(path));
if (relevant.length === 0) {
console.log(
`[rust-warnings] 本次推送未触碰 Rust 代码(${changed.size} 个文件),跳过告警检查。`,
);
process.exit(0);
}
const profiles =
process.platform === 'win32' ? ['agc-windows', 'server-rs'] : ['server-rs'];
console.log(
`[rust-warnings] 本次推送触碰 Rust 文件 ${relevant.length} 个,检查 profile: ${profiles.join(', ')}`,
);
const args = [
'scripts/check-rust-warnings.mjs',
...profiles.map((name) => `--profile=${name}`),
];
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
stdio: 'inherit',
});
if (result.status !== 0) {
console.error(
'[rust-warnings] 推送被拦下:请先处理上面的新增告警(详见 dev 运维文档「Rust 编译告警门禁」)。',
);
process.exit(result.status ?? 1);
}
File diff suppressed because it is too large Load Diff