5.3 保留字与命名反模式
5.1 讲了「怎么写对」,5.2 讲了「放哪儿对」。这一节讲反面:哪些名字从一开始就不该出现。反模式的破坏力比「格式不统一」大得多——格式错只是不好看,保留字错是真的会坏。
举一个具体的:《星屑旅店》有个 UI 资产被命名为 ui_slot-class_v01.png。文件名本身能解析(前缀 ui、subject slot-class、版本 v01),命名 lint 不报错。但打包到 Web 端后,某个工具链把它当模块名处理,class 是 JS 保留字,加载直接失败。这类词不是「不推荐」,是「用了就出事」。
所以这一节把反模式收成两张表:保留字黑名单(用了会坏)和反模式(用了会持续付代价)。判据同样落成可跑的 lint,和 5.1、5.2 共用同一份资产清单。
5.3.1 四张黑名单
四类词各自有明确的「为什么会坏」:
| 黑名单 | 例子 | 为什么会坏 |
|---|---|---|
osReserved | con / prn / aux / nul / com1 | Windows 文件系统保留名,某些打包工具会在这些名字上直接失败 |
engineReserved | new / class / default / null / true | 会撞代码标识符,代码生成、模块化打包、脚本绑定都会踩 |
tempMarkers | copy / temp / old / final / wip | 临时态词进了正式资产名,说明「这个文件是临时的」这件事没被处理干净 |
synonyms | img→image / bg→background / btn→button | 同一概念两种写法,代码里匹配时要写两个分支 |
tempMarkers 这条最容易被忽视。furn_lamp-copy_v01.png 名字合法,但它暴露了一个事实:有人复制了一个文件准备改,改完忘了删原件。lint 报出来不是为了罚人,是为了在评审前把「临时资产混进正式清单」这件事变成可见的。
5.3.2 黑名单与别名表
这份配置是 D 层:它不产出数字,只声明「哪些词不能用」和「哪些词要归一」:
{
"ruleSet": "reserved",
"specVersion": 1,
"note": "保留字黑名单与命名反模式。osReserved 命中文件系统/打包工具保留名;engineReserved 命中会与代码标识符冲突的词;tempMarkers 命中「临时态」词;synonyms 是需要归一到规范词的别名。规则是字符串,两侧逐位一致。",
"osReserved": ["con", "prn", "aux", "nul", "com1", "com2", "com3", "lpt1", "lpt2"],
"engineReserved": ["new", "class", "default", "null", "true", "false", "undefined", "function", "return", "delete", "import", "export", "var", "let", "const"],
"tempMarkers": ["copy", "temp", "tmp", "bak", "backup", "old", "final", "fix", "test", "draft", "wip", "todo"],
"synonyms": {
"pic": "image",
"img": "image",
"bg": "background",
"char": "character",
"anim": "animation",
"tex": "texture",
"fx": "vfx",
"btn": "button"
},
"maxSubjectLen": 24,
"maxBaseLen": 40
}
注意 synonyms 的键是「不推荐写法」,值是「规范写法」。它同时充当一份归一表:lint 用它判 E_SYNONYM,工具链也可以用它做批量重命名。表里 char→character 有个微妙点——char 恰好是角色资产的前缀(5.1 定的),所以判词时要先剥掉前缀,否则每个角色资产都会被误报。这正是下面脚本里 words_of 要做「去掉首段」的原因。
5.3.3 反模式:临时态与超长名
除了黑名单,还有两条长度规则:
E_SUBJLONG:subject 超过 24 字符。subject 是「这个资产是什么」,太长说明有人在名字里塞了描述(wardrobe-with-mirror-and-drawers),应该拆成subject+variant或放进清单的描述字段。E_LONG:整个文件名超过 40 字符。某些工具链与路径长度限制对超长文件名不友好,且超长名通常是「描述塞进名字」的同一个病。
两条规则都设成整数阈值(24 / 40),写在配置里,两侧共用——换阈值只改一处。
5.3.4 S 层:Python 保留字 lint
下面这段是 S 层脚本。它先把文件名拆成候选词(剥掉 kind 前缀与版本段),再逐词对照四张表:
#!/usr/bin/env python3
# file: sim/sp05_reserved.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp05_reserved.py
"""保留字与命名反模式 lint(S 层):把文件名拆成「候选词」(去掉 kind 前缀与版本段后,
按下划线/连字符切开),逐词对照四张黑名单 osReserved / engineReserved / tempMarkers /
synonyms,再加两条长度规则。产出 out/sp05_reserved.json 供 P 层对照。两侧逐位一致。
"""
import json, os, re, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS = os.path.join(ROOT, "config/sp05_assets.json")
RULES = os.path.join(ROOT, "config/sp05_reserved.json")
OUT = os.path.join(ROOT, "out")
def words_of(base: str) -> tuple:
"""返回(候选词, subject):去掉首段(kind 前缀)与末段(若为版本),其余按连字符切开。"""
tokens = base.split("_")
subject = tokens[1] if len(tokens) >= 2 else ""
body = tokens[1:]
if body and re.match(r"^v[0-9]{2}$", body[-1]):
body = body[:-1]
words = []
for t in body:
words.extend(t.split("-"))
return [w for w in words if w], subject
def lint_one(asset: dict, rules: dict) -> list:
base = os.path.splitext(os.path.basename(asset["file"]))[0]
words, subject = words_of(base)
out = []
for w in words:
if w in rules["osReserved"]:
out.append({"id": asset["id"], "code": "E_OSRESERVED", "token": w})
if w in rules["engineReserved"]:
out.append({"id": asset["id"], "code": "E_ENGINE", "token": w})
if w in rules["tempMarkers"]:
out.append({"id": asset["id"], "code": "E_TEMP", "token": w})
if w in rules["synonyms"]:
out.append({"id": asset["id"], "code": "E_SYNONYM", "token": w})
if len(subject) > rules["maxSubjectLen"]:
out.append({"id": asset["id"], "code": "E_SUBJLONG", "token": str(len(subject))})
if len(base) > rules["maxBaseLen"]:
out.append({"id": asset["id"], "code": "E_LONG", "token": str(len(base))})
return sorted(out, key=lambda v: v["code"])
def main() -> int:
assets = json.load(open(ASSETS, encoding="utf-8"))["assets"]
rules = json.load(open(RULES, encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
violations = []
for a in assets:
violations.extend(lint_one(a, rules))
violations.sort(key=lambda v: (v["id"], v["code"]))
counts = {}
for v in violations:
counts[v["code"]] = counts.get(v["code"], 0) + 1
summary = {"project": "stardust-inn", "ruleSet": rules["ruleSet"],
"assetCount": len(assets), "violationCount": len(violations),
"counts": counts, "violations": violations}
with open(os.path.join(OUT, "sp05_reserved.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"assetCount={len(assets)} violations={len(violations)}")
for code in sorted(counts):
print(f" {code}={counts[code]}")
print("wrote out/sp05_reserved.json")
return 0
if __name__ == "__main__":
sys.exit(main())
5.3.5 P 层:TS 校验器
// file: core/src/sp05/reserved.ts
// tier: P
// verify: node --test 'core/test/sp05_*.test.ts'
// 保留字与命名反模式的 TS 侧实现:与 sim/sp05_reserved.py 共用 config/sp05_reserved.json。
// 规则是字符串/整数,两侧违例集合逐位一致。只允许可擦除 TS 语法。
export type Violation = { id: string; code: string; token: string };
export type Asset = { id: string; kind: string; file: string };
export type ReservedRules = { osReserved: string[]; engineReserved: string[]; tempMarkers: string[];
synonyms: Record<string, string>; maxSubjectLen: number; maxBaseLen: number };
function baseName(path: string): string {
const file = path.slice(path.lastIndexOf("/") + 1);
const dot = file.lastIndexOf(".");
return dot > 0 ? file.slice(0, dot) : file;
}
/** 去掉首段(kind 前缀)与末段(若为版本),其余按下划线/连字符切成候选词。 */
export function wordsOf(base: string): { words: string[]; subject: string } {
const tokens = base.split("_");
const subject = tokens.length >= 2 ? tokens[1] : "";
let body = tokens.slice(1);
if (body.length > 0 && /^v[0-9]{2}$/.test(body[body.length - 1])) body = body.slice(0, -1);
const words: string[] = [];
for (const t of body) for (const w of t.split("-")) if (w) words.push(w);
return { words, subject };
}
export function lintReserved(assets: Asset[], rules: ReservedRules): Violation[] {
const out: Violation[] = [];
for (const a of assets) {
const base = baseName(a.file);
const { words, subject } = wordsOf(base);
for (const w of words) {
if (rules.osReserved.includes(w)) out.push({ id: a.id, code: "E_OSRESERVED", token: w });
if (rules.engineReserved.includes(w)) out.push({ id: a.id, code: "E_ENGINE", token: w });
if (rules.tempMarkers.includes(w)) out.push({ id: a.id, code: "E_TEMP", token: w });
if (Object.prototype.hasOwnProperty.call(rules.synonyms, w)) out.push({ id: a.id, code: "E_SYNONYM", token: w });
}
if (subject.length > rules.maxSubjectLen) out.push({ id: a.id, code: "E_SUBJLONG", token: String(subject.length) });
if (base.length > rules.maxBaseLen) out.push({ id: a.id, code: "E_LONG", token: String(base.length) });
}
return out.sort((x, y) =>
x.id < y.id ? -1 : x.id > y.id ? 1 : x.code < y.code ? -1 : x.code > y.code ? 1 : 0);
}
/** 归一:把别名映射到规范词,返回规范名(不在表内则原样返回)。 */
export function canonicalize(word: string, rules: ReservedRules): string {
return rules.synonyms[word] ?? word;
}
5.3.6 P 层测试:对照 Python 产物
// file: core/test/sp05_reserved.test.ts
// tier: P
// verify: node --test 'core/test/sp05_*.test.ts'
// 跨语言对照:读 sim/sp05_reserved.py 落盘的 out/sp05_reserved.json,断言 TS 侧 lintReserved
// 的违例集合与之一致。负例断言:保留字 / 临时态词 / 别名 / 超长名都必须 fail。
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 { lintReserved, wordsOf, canonicalize } from "../src/sp05/reserved.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const assets = JSON.parse(readFileSync(resolve(ROOT, "config/sp05_assets.json"), "utf8")).assets;
const rules = JSON.parse(readFileSync(resolve(ROOT, "config/sp05_reserved.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp05_reserved.json"), "utf8"));
const key = (v: { id: string; code: string; token: string }): string => `${v.id}:${v.code}:${v.token}`;
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
test("跨语言:TS 违例集合 == Python 违例集合(逐位一致)", () => {
const mine = lintReserved(assets, rules).map(key);
const theirs = (py.violations as { id: string; code: string; token: string }[]).map(key);
assert.deepEqual(mine, theirs);
assert.equal(digest(mine), digest(theirs));
console.log(`reserved digest = ${digest(mine)}`);
});
test("负例断言:保留字 / 临时态 / 别名 / 超长都必须 fail", () => {
const cases: [string, string, string][] = [
["ui_nul-slot_v01", "E_OSRESERVED", "nul"],
["ui_slot-class_v01", "E_ENGINE", "class"],
["furn_lamp-copy_v01", "E_TEMP", "copy"],
["char_avatar_old_v01", "E_TEMP", "old"],
["ui_button_bg_v01", "E_SYNONYM", "bg"],
["icon_coin-img_v01", "E_SYNONYM", "img"],
["furn_wardrobe-with-mirror-drawers_base_v01", "E_SUBJLONG", "28"],
];
const byId = new Map<string, string[]>();
for (const v of lintReserved(assets, rules)) byId.set(v.id, [...(byId.get(v.id) ?? []), `${v.code}:${v.token}`]);
for (const [id, code, token] of cases) {
assert.ok(byId.get(id)?.includes(`${code}:${token}`), `${id} 应报 ${code}:${token},实得 ${byId.get(id)}`);
}
});
test("正例、词切分与别名归一", () => {
const flagged = new Set(lintReserved(assets, rules).map((v) => v.id));
for (const id of ["furn_lamp-base_v01", "vfx_steam-puff_v01", "icon_coin-gold_v01"]) {
assert.ok(!flagged.has(id), `${id} 不应有违例`);
}
assert.deepEqual(wordsOf("char_avatar_old_v01").words, ["avatar", "old"]);
assert.deepEqual(wordsOf("ui_nul-slot_v01").words, ["nul", "slot"]);
assert.equal(canonicalize("img", rules), "image");
assert.equal(canonicalize("lamp", rules), "lamp");
});
5.3.7 实跑输出
assetCount=24 violations=8
E_ENGINE=1
E_LONG=1
E_OSRESERVED=1
E_SUBJLONG=1
E_SYNONYM=2
E_TEMP=2
P 层 node --test 'core/test/sp05_*.test.ts':pass 3 / fail 0,digest d71310847bc6984f(两侧逐位一致)。八条违例里,char_avatar_old_v01 报了 E_TEMP(old)——注意 old 在名字里是变体位,不在 subject 里,但判词时它仍然参与匹配。这正是 words_of 的设计:它剥掉的是前缀与版本段,中间所有段(含变体)都进词表。
5.3.8 决策与取舍
保留字表该多长。 越长越安全,但误报也越多。我们的取舍是:只收「真的会坏」的词(OS 保留名、语言保留字)与「反复出现」的临时态词,不收「听起来不专业」的词(比如 thing / stuff)——那种靠评审,不靠 lint。
E_SYNONYM 为什么和 E_TEMP 分开。 两者的修法不同:E_TEMP 要删或改(临时资产不该进正式清单),E_SYNONYM 要归一(img 改成 image 就行,资产还在)。分码能让 CI 的修复建议更具体。
长度阈值 24 / 40 从哪来。 24 是「subject 描述一个东西够用」的经验值,40 是常见路径长度限制的保守余量。它们不是物理常数,随项目调整——但必须写在配置里、两侧共用,不能一边写死一边读配置。
命名、目录、保留字三节合起来,构成《星屑旅店》的「命名规范」全貌:名字要合文法、放在对的目录、不碰黑名单。下一章把这些名字接到资产清单上——清单字段、引用与依赖声明、清单与目录的双向一致,命名 lint 会作为清单校验的第一道闸门被复用。
阅读导航:上一节:5.2 目录层级与引擎内引用约束 · 下一节:6.1 资产清单的字段设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。