3.3 导入失败分级与修复
3.1 与 3.2 各报了一批问题:.meta 契约审计 8 条、规则引擎 8 条,合计 16 条。但「报了 16 条」不是结论——结论是这 16 条里哪几条必须拦下整批导入、哪几条只需人工确认、哪几条只是记录在案。本节把两批问题合成一份分级清单,用一张可判定的表回答「谁拦、谁放、谁修」。
这也是「一份视觉规范,三处消费、一处生成」在管线侧的收口:3.1 的 out/ta03_meta.json 与 3.2 的 out/ta03_rules.json 是输入,config/ta03_grading.json 是判据,输出是一份带级别的清单——同一个规则码在 Python 与 TS 两侧必须落进同一级,且必须给出同一个修复动作。
3.3.1 三级:block / warn / info
分级只设三级,每级的动作必须不同,否则分级没有意义:
| 级别 | 含义 | 动作 | 门禁 |
|---|---|---|---|
block | 违反契约的硬约束 | 整批导入失败 | exit 1 |
warn | 可疑但可能刻意 | 人工确认后放行 | 不拦 |
info | 记录,供后续判断 | 只写日志 | 不拦 |
判据是**「能不能继续」**,不是「严不严重」。E_SIZE_POT(非 2 的幂)听起来比 E_SIZE_MIN(图太小)严重,但两者都只意味着「导入器会多一次缩放」,都不阻断;而 E_META_MISSING_IDENTITY(GUID 丢失)一旦放过,引用它的所有资产全部指向一个不存在的身份,必须拦。级别的判据是后果的不可逆性,不是数值的大小。
3.3.2 分级表:12 个规则码的可判定映射
分级表把两批问题涉及的 12 个规则码逐个映射到级别、修复动作、能否自动修:
{
"ruleSet": "grading",
"specVersion": 1,
"note": "导入失败分级表:把 3.1(.meta 契约审计)与 3.2(导入期规则引擎)报出的每个规则码映射到 block / warn / info 三级,并给出修复动作与「能否自动修」。级别与 autoFixable 都是枚举字符串,跨语言逐位一致。",
"levels": ["block", "warn", "info"],
"policy": {
"note": "任一 block 即阻断该批导入(构建失败);warn 需人工确认后放行;info 只记录不拦"
},
"table": [
{"code": "E_META_MISSING_IDENTITY", "level": "block", "autoFixable": false, "fix": "补回 GUID:身份不可从源文件推导,只能从版本库历史恢复"},
{"code": "E_FORMAT_EXT", "level": "block", "autoFixable": true, "fix": "把导入设置里的格式改回与扩展名一致,或改名后重新导出"},
{"code": "E_SIZE_MAX", "level": "block", "autoFixable": false, "fix": "降分辨率到不超过 maxDimension,或申请更高档位的导入上限"},
{"code": "E_SIZE_POT", "level": "warn", "autoFixable": false, "fix": "改画布到 2 的幂(256/512/1024),避免导入器二次缩放"},
{"code": "E_SIZE_MIN", "level": "warn", "autoFixable": false, "fix": "小于 minDimension 的图并入图集,不单独成文件"},
{"code": "E_FORMAT", "level": "warn", "autoFixable": true, "fix": "换成该类型允许的格式(tile 只收 png)"},
{"code": "E_NAME", "level": "warn", "autoFixable": true, "fix": "按命名文法重命名,并同步更新引用它的清单条目"},
{"code": "E_PREFIX", "level": "warn", "autoFixable": true, "fix": "前缀与 kind 对齐(tile 用 tile_ 前缀)"},
{"code": "E_META_MISSING_SETTING", "level": "warn", "autoFixable": true, "fix": "补回导入设置字段;导入器有默认值也必须显式写进 .meta"},
{"code": "W_META_DERIVED_DRIFT", "level": "warn", "autoFixable": true, "fix": "重算派生字段;或从 .meta 删掉它,改为导入时现算"},
{"code": "E_ASPECT", "level": "info", "autoFixable": false, "fix": "确认长宽比是否刻意;非刻意则裁切到 1:4 以内"},
{"code": "I_META_UNKNOWN_FIELD", "level": "info", "autoFixable": false, "fix": "未知字段:要么写进 schema 成为契约,要么删除"}
]
}
两个字段值得单独说。autoFixable:E_FORMAT_EXT、E_FORMAT、E_NAME、E_PREFIX、E_META_MISSING_SETTING、W_META_DERIVED_DRIFT 可以自动修——改名、改格式、重算派生字段都是确定的变换,机器做不会比人差;E_SIZE_MAX 不行,降分辨率会改变画面,必须人来定。fix:每条都必须写成一个动作,不是一句「请注意」。分级表的价值一半在级别,一半在「照着做就行」。
3.3.3 S 层:把两批问题合成一份分级清单
分级器读上两节的产物,逐条查表定级:
#!/usr/bin/env python3
# file: sim/ta03_grade.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta03_grade.py
"""3.3 的 S 层分级器:把 3.1 的 .meta 审计问题(out/ta03_meta.json)与 3.2 的规则违例
(out/ta03_rules.json)合成一份分级清单,按 config/ta03_grading.json 的映射表标 block /
warn / info,并给出修复动作与「能否自动修」。分级表未覆盖的规则码一律判失败(不许漏级)。
落 out/ta03_grade.json 与 out/ta03_grade.csv。
"""
import csv
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
GRADING = os.path.join(ROOT, "config/ta03_grading.json")
def main() -> int:
grading = json.load(open(GRADING, encoding="utf-8"))
meta = json.load(open(os.path.join(OUT, "ta03_meta.json"), encoding="utf-8"))
rules = json.load(open(os.path.join(OUT, "ta03_rules.json"), encoding="utf-8"))
by_code = {t["code"]: t for t in grading["table"]}
graded, unmapped = [], []
for src, items in (("meta", meta["issues"]), ("rules", rules["violations"])):
for v in items:
t = by_code.get(v["code"])
if t is None:
unmapped.append(v["code"])
continue
graded.append({"id": v["id"], "code": v["code"], "level": t["level"],
"autoFixable": t["autoFixable"], "source": src, "fix": t["fix"]})
if unmapped:
for c in sorted(set(unmapped)):
print(f"FAIL 分级表未覆盖规则码 {c}")
return 1
graded.sort(key=lambda g: (g["id"], g["code"]))
levels = {lv: 0 for lv in grading["levels"]}
codes = {}
for g in graded:
levels[g["level"]] += 1
codes[g["code"]] = codes.get(g["code"], 0) + 1
blocked = sorted({g["id"] for g in graded if g["level"] == "block"})
auto = sum(1 for g in graded if g["autoFixable"])
digest = hashlib.sha256(
"\n".join(f"{g['id']}:{g['code']}:{g['level']}" for g in graded).encode("utf-8")
).hexdigest()[:16]
report = {"project": meta["project"], "ruleSet": grading["ruleSet"],
"issueCount": len(graded), "levelCounts": levels, "codeCounts": codes,
"blockedRecords": blocked, "autoFixableCount": auto,
"gate": "block" if blocked else "pass", "digest": digest, "issues": graded}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ta03_grade.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
with open(os.path.join(OUT, "ta03_grade.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "code", "level", "autoFixable", "source"])
for g in graded:
w.writerow([g["id"], g["code"], g["level"], str(g["autoFixable"]).lower(), g["source"]])
print(f"issueCount={len(graded)} block={levels['block']} warn={levels['warn']} "
f"info={levels['info']} autoFixable={auto}")
print(f"blockedRecords={len(blocked)} gate={report['gate']} digest={digest}")
for g in graded:
print(f" {g['level']:5s} {g['code']:26s} {g['id']}")
print("wrote out/ta03_grade.json out/ta03_grade.csv")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta03_grade.py(只列 3 条 block;其余 13 条 warn / info 见 out/ta03_grade.json 与 out/ta03_grade.csv):
issueCount=16 block=3 warn=11 info=2 autoFixable=10
blockedRecords=2 gate=block digest=65514ebee2fb5c84
block E_SIZE_MAX ui_panel-wood_v01
block E_FORMAT_EXT vfx_steam-puff_v01
block E_META_MISSING_IDENTITY vfx_steam-puff_v01
wrote out/ta03_grade.json out/ta03_grade.csv
16 条正好等于 3.1 的 8 条加 3.2 的 8 条——分级不新增问题,只给问题定级。分布是 block 3 / warn 11 / info 2,其中 10 条可自动修。3 条 block 落在 2 条记录上:ui_panel-wood_v01(4096 超上限)与 vfx_steam-puff_v01(格式与扩展名矛盾 + GUID 丢失)。gate=block 意味着这批导入不能进——分级器不只是列问题,而是给出一个布尔结论。
3.3.4 P 层:分级内核
TS 侧独立实现,只读同一份分级表与上两节的 JSON:
// file: core/src/ta03/grade.ts
// tier: P
// verify: node --test 'core/test/ta03_*.test.ts'
// 3.3 的 P 层分级内核:与 sim/ta03_grade.py 共用同一份 config/ta03_grading.json。
// 分级表是「规则码 → 级别」的枚举映射,输出只含字符串,故两侧分级清单逐位一致。
// 只允许可擦除 TS 语法。
export type GradeRule = { code: string; level: string; autoFixable: boolean; fix: string };
export type Grading = { levels: string[]; table: GradeRule[] };
export type RawIssue = { id: string; code: string };
export type GradedIssue = { id: string; code: string; level: string; autoFixable: boolean; source: string; fix: string };
const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
export function grade(
metaIssues: RawIssue[],
ruleViolations: RawIssue[],
grading: Grading,
): { issues: GradedIssue[]; unmapped: string[] } {
const byCode = new Map<string, GradeRule>();
for (const t of grading.table) byCode.set(t.code, t);
const issues: GradedIssue[] = [];
const unmapped: string[] = [];
const sources: [string, RawIssue[]][] = [["meta", metaIssues], ["rules", ruleViolations]];
for (const [source, items] of sources) {
for (const v of items) {
const t = byCode.get(v.code);
if (t === undefined) { unmapped.push(v.code); continue; }
issues.push({ id: v.id, code: v.code, level: t.level, autoFixable: t.autoFixable, source, fix: t.fix });
}
}
issues.sort((x, y) => cmp(x.id, y.id) || cmp(x.code, y.code));
return { issues, unmapped: [...new Set(unmapped)].sort() };
}
/** 与 sim/ta03_grade.py 的分级摘要逐项对齐。 */
export function summarize(issues: GradedIssue[], levels: string[]) {
const levelCounts: Record<string, number> = {};
for (const lv of levels) levelCounts[lv] = 0;
const codeCounts: Record<string, number> = {};
for (const g of issues) {
levelCounts[g.level] = (levelCounts[g.level] ?? 0) + 1;
codeCounts[g.code] = (codeCounts[g.code] ?? 0) + 1;
}
const blockedRecords = [...new Set(issues.filter((g) => g.level === "block").map((g) => g.id))].sort();
return {
levelCounts,
codeCounts,
blockedRecords,
autoFixableCount: issues.filter((g) => g.autoFixable).length,
gate: blockedRecords.length > 0 ? "block" : "pass",
};
}
四条测试里两条是负例,分别守「漏级必须报错」与「只有 block 才阻断」:
// file: core/test/ta03_grade.test.ts
// tier: P
// verify: node --test 'core/test/ta03_*.test.ts'
// 跨语言对照:读 sim/ta03_grade.py 落盘的 out/ta03_grade.json,断言 TS 侧分级内核复算出的
// 分级清单与摘要与之一致(枚举字符串类,逐位一致)。负例:分级表未覆盖的规则码必须报出来,
// 而不是静默当成 info;任一 block 必须让门禁判阻断。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { grade, summarize } from "../src/ta03/grade.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const grading = JSON.parse(readFileSync(resolve(ROOT, "config/ta03_grading.json"), "utf8"));
const meta = JSON.parse(readFileSync(resolve(ROOT, "out/ta03_meta.json"), "utf8"));
const rules = JSON.parse(readFileSync(resolve(ROOT, "out/ta03_rules.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta03_grade.json"), "utf8"));
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
test("跨语言:TS 分级清单 == Python 分级清单(逐位一致)", () => {
const r = grade(meta.issues, rules.violations, grading);
assert.deepEqual(r.unmapped, []);
const mine = r.issues.map((g) => `${g.id}:${g.code}:${g.level}`);
const theirs = (py.issues as { id: string; code: string; level: string }[]).map((g) => `${g.id}:${g.code}:${g.level}`);
assert.deepEqual(mine, theirs);
assert.equal(digest(mine), py.digest);
assert.deepEqual(summarize(r.issues, grading.levels), {
levelCounts: py.levelCounts, codeCounts: py.codeCounts, blockedRecords: py.blockedRecords,
autoFixableCount: py.autoFixableCount, gate: py.gate,
});
console.log(`grade digest = ${py.digest}`);
});
test("分级计数:16 条问题 = block 3 + warn 11 + info 2,门禁判阻断", () => {
const r = grade(meta.issues, rules.violations, grading);
assert.deepEqual(r.issues.length, 16);
const s = summarize(r.issues, grading.levels);
assert.deepEqual(s.levelCounts, { block: 3, warn: 11, info: 2 });
assert.deepEqual(s.blockedRecords, ["ui_panel-wood_v01", "vfx_steam-puff_v01"]);
assert.equal(s.gate, "block");
assert.equal(s.autoFixableCount, 10);
});
test("负例:分级表未覆盖的规则码必须报错,不许默认放行", () => {
const r = grade([{ id: "x", code: "E_UNKNOWN_RULE" }], [], grading);
assert.deepEqual(r.unmapped, ["E_UNKNOWN_RULE"]);
assert.deepEqual(r.issues, []);
});
test("负例:只把 block 当阻断;warn / info 不阻断", () => {
const warnOnly = grade([{ id: "a", code: "E_NAME" }], [{ id: "b", code: "E_ASPECT" }], grading);
assert.equal(summarize(warnOnly.issues, grading.levels).gate, "pass");
const withBlock = grade([{ id: "a", code: "E_SIZE_MAX" }], [], grading);
assert.equal(summarize(withBlock.issues, grading.levels).gate, "block");
assert.deepEqual(summarize(withBlock.issues, grading.levels).blockedRecords, ["a"]);
});
实跑 node --test 'core/test/ta03_*.test.ts'(本节 4 条;本章三节合计 12 条):
grade digest = 65514ebee2fb5c84
✔ 跨语言:TS 分级清单 == Python 分级清单(逐位一致)
✔ 分级计数:16 条问题 = block 3 + warn 11 + info 2,门禁判阻断
✔ 负例:分级表未覆盖的规则码必须报错,不许默认放行
✔ 负例:只把 block 当阻断;warn / info 不阻断
ℹ pass 12
ℹ fail 0
两侧 digest 都是 65514ebee2fb5c84。分级输出全是枚举字符串(级别名、规则码),没有浮点,所以是逐位一致。
3.3.5 「未覆盖即失败」:分级表必须穷尽
分级器最容易出的错,是规则码新增了、分级表忘了同步,于是新码静默落进某个默认级别——要么被当成 info 悄悄放过,要么被当成 block 误拦。本节的写法把这条路堵死:by_code.get(code) 取不到就进 unmapped,非空即 exit 1 且不产出清单。测试里有一条负例专门守它——喂一个不存在的 E_UNKNOWN_RULE,必须被报出来,且不许出现在分级清单里。
这条纪律和 3.2 的「每条规则要有负例」是同一件事的两面:判据的完整性本身要被断言。一个规则码有没有级,不能靠「我记得加过」,得靠一条会失败的测试。
3.3.6 边界:真实导入器的行为不可复现
本节判的全是记录层的字段与规则码,这些是纯数据,能逐位复现。但「导入器拿到这批设置后到底会不会二次缩放、GUID 冲突时它如何取舍、重导后派生字段是否真的重算」——需真实环境,命令行不可复现:那取决于具体 DCC 与引擎版本内部的导入实现,本仓库既没装 DCC,也没有引擎的导入器。本节把这条边界标成 X 层,只断言「按记录该判什么级」,不断言「引擎会怎么做」。
这也解释了 3.1 为什么要把 .meta 的契约字段与派生字段分开:契约字段是规范,能逐位断言;派生字段是快照,它的漂移能被检出,但「重算后是否一致」要留给真实导入器验证。分级器只对前者下 block,对后者的漂移只下 warn——这正是分级表里 E_META_MISSING_IDENTITY 是 block、而 W_META_DERIVED_DRIFT 只是 warn 的原因。
阅读导航:上一节:3.2 导入期校验:尺寸、格式、命名 · 下一节:4.1 依赖图:显式引用与隐式引用 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。