5.3 保留字与命名反模式

命名最贵的一课不是「怎么写对」,而是「哪些词根本不能用」:讲清四张黑名单(文件系统保留名、会撞代码标识符的词、临时态词、待归一别名)与两条长度规则,落成可跑的 Python + TypeScript lint,让 nul / class / copy / img 这类词在构建期就被拦下。

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 四张黑名单

四类词各自有明确的「为什么会坏」:

黑名单例子为什么会坏
osReservedcon / prn / aux / nul / com1Windows 文件系统保留名,某些打包工具会在这些名字上直接失败
engineReservednew / class / default / null / true会撞代码标识符,代码生成、模块化打包、脚本绑定都会踩
tempMarkerscopy / temp / old / final / wip临时态词进了正式资产名,说明「这个文件是临时的」这件事没被处理干净
synonymsimg→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 资产清单的字段设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则