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:
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+20
-2187
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user