3420eb2cd1
默认 target 目录改为 cargo metadata 报的 target_directory(开发者本来就在用的、依赖已热),保留 GENARRATIVE_WARNCHECK_TARGET_DIR 覆盖供 CI 使用;fresh 完整性断言仍是安全网(本 profile 的包没被重编就 exit 1 报测量不完整,不会静默给出新增 0)。热目录实测:agc-windows 7.9s、server-rs 9.0s。文档补一句:CI 挂在 repository-checks(有工具链与依赖、不会红,但无 cargo 预热缓存,估 25-40 分钟),接线方案待定。
486 lines
19 KiB
JavaScript
486 lines
19 KiB
JavaScript
#!/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);
|
||
}
|
||
|
||
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,
|
||
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(无新增告警)');
|