Community写作与编辑github.com

jessegall/commandments-writing-detectors

如何编写你自己的“诫命”——一条项目本地规则,从此可以评判每个文件。在编写或修改 `.commandments/custom/` 中的规则、用户要求新规则/检查/检测器、`commandments make` 指向此处,或你正准备使用正则表达式检查代码时,请先阅读此技能。

commandments-writing-detectors 是什么?

该技能详细介绍了如何在 Commandments 框架中为项目编写自定义规则(即“诫命”)。规则在本质上是可以由 CLI 工具执行的数据文件(`.json`),它们被放置在 `.commandments/custom/` 目录下,并按照相同的核心引擎进行评判,该引擎也用于内置规则。每条规则都指定了目标引擎、所检测的违规行为以及一个查找模式,该模式包含一个选择器(例如 `call`、`function`、`type-declaration`)以及一系列 `where` 和 `reject` 步骤,用于在抽象语法树(AST)中定位代码节点。技能详细描述了所有可用的检查项,涵盖节点的类型、名称、引用解析、类型、位置、文档注释等多个维度。此外,它还支持规则组合:通过 `{"rule": "SomeRule"}` 字段引用其他已定义的规则,并支持逻辑操作符(如 `any`、`all`、`not`)来构建更复杂的条件。该技能适用于 PHP、Python、C# 和 TypeScript 等多种语言平台,使开发者能够以声明式、可重复使用的方式定义代码约定,无需编写自定义脚本或复杂的 AST 遍历逻辑。

兼容平台✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/jessegall/code-commandments/tree/HEAD/skills/writing-detectors

Installed? Explore more 写作与编辑 skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

编写你自己的诫命

内置规则并非天花板。你在审查中不断重复的约定,就是一条你尚未编写的规则。把它写一次,它就会从此评判每个文件。

规则是引擎回答的一个问题

你的自定义规则是二进制文件运行的数据——一个位于 .commandments/custom/ 目录下的 .json 文件,可以在该目录或其下的任何子目录中,因此一个项目可以为代码的不同部分分组管理规则(如 custom/core/、custom/viewer/)。它使用与每个内置规则相同的引擎进行评判。规则由其文件位置命名,因此移动规则到某个文件夹后,配置中对它的所有引用仍然有效;如果存在两个同名文件,两者都会被拒绝,而不是其中一个静默胜出。规则指定了它评判的引擎、发现的违规行为以及一个查询:一个选择器用于打开节点,然后是一系列 where 步骤(保留节点)或 reject 步骤(丢弃节点)。每个步骤恰好执行一个检查;规则的好坏完全取决于每个步骤所提出的问题。

{
    "engine": "php",
    "sin": {
        "name": "raw-sql",
        "description": "SQL 内联写在调用点。",
        "rule": "查询应通过拥有该表的仓库进行。",
        "skill": "no-raw-sql"
    },
    "find": {
        "select": "call",
        "where": [
            {"resolves": "Illuminate\\Support\\Facades\\DB", "of": "child:class"},
            {"nameIn": ["select", "statement", "unprepared"]}
        ],
        "reject": [
            {"file": "*Repository.php"}
        ]
    }
}

选择器是一种中立的类型,在每种语言中含义相同——call、construction、function、type-declaration、assignment、loop、branch、return、throw、catch、comparison、literal、member-access、null-safe、identifier、import、parameter、block、bail-out、expression-statement、self-reference——或者 kind:<Kind>,即语言自身的节点类型(如 kind:Expr_MethodCall),当没有中立类型可描述时使用。

规则由其他规则构建。 你已经编写过一次的检查可以被命名,无需重复编写:{"rule": "ActionClass"} 当该名称的规则也找到该节点时通过;而 find 中的 "from": "ActionClass"(代替 "select")则缩小了该规则找到的范围,这意味着一规则嵌入另一规则中读取。两者都能引用 .commandments/custom/ 中同一引擎下的任何规则,每个部分仍然是独立的规则,可以独立列出和运行。未声明的名称、其他引擎的规则以及循环引用链都会在规则读取时被拒绝,在任何评判之前。

使用 either 和 not,无需第二个规则。 {"any": [{"is": "loop"}, {"is": "branch"}]} 在其任意一个步骤通过时通过;{"all": [...]} 在所有步骤都通过时通过;{"not": {...}} 在其步骤不通过时通过——正是这样,where 才能表达只有 reject 才能做到的事情。

一个步骤的单一检查——所有可用检查项,由工具自身生成:

节点是什么以及如何称呼

检查项保留节点当
{"is": "loop"}它符合中立类型
{"kind": "Expr_StaticCall"}它是语言自身的类型,按语言的写法或词汇表拼写:Expr_StaticCall 和 static-call 是同一个结构。拼写派生自类型,因此编辑器的自动补全会列出语言实际发出的内容,而两个类型共享的拼写则不属于任何一个
{"name": "select"}它的名称——调用或访问的名称是其子节点名称——正好匹配
{"nameIn": ["select", "statement"]}它的名称是其中之一
{"nameLike": "get*"}它的名称匹配 glob 模式,* 表示任意字符序列,? 表示一个字符
`{"nameMatches": "^(getset)[A-Z]"}`
{"nameMood": "question"}它的名称属于该语言语气:bare-verb 是动词本身(holds、ships),third-person 叙述动词(entersTestMode),question 以疑问词开头(is、has、can、should)。它读的是语气,而不是规则自己写的单词列表。这是唯一一个评判名称的检查,它只回答名称是否以某种方式解读:什么构成了违规,取决于语气与功能的反差,因此需要与成员的回答或是否更改任何内容配对使用,如同内置规则一样
{"nameCase": "snake"}它的名称,去掉前导的 $ 或 _,采用 camel、pascal、snake、upper 或 kebab 大小写
{"hasModifier": "static"}它携带该修饰符
{"hasFlag": "byRef"}它携带语言树设置该标志
{"operator": "*"}它的操作符正好匹配:* 和 ?? 区分两个二元表达式,+= 区分赋值,! 区分一元表达式,extends 区分继承子句
{"operatorIn": ["*", "/"]}它的操作符是其中之一

它说了什么

检查项保留节点当
{"text": "select * from users"}字面量的文本正好匹配
{"textLike": "*where id*"}字面量的文本匹配 glob 模式
{"textMatches": "(?i)^select"}字面量的文本匹配正则表达式
{"commentIs": "history"}注释属于校准过的散文解读之一:history 叙述代码为何被修改而非其功能,strawman 为没人会犯的错误辩护,restates 没有说明其文档编写的代码之外的任何信息。这些解读是散文包自身的产物,在每种语言中通过测试进行校准,且集合是封闭的:规则要求的是已经校准的判断,而不是匹配自身编写的单词的散文。当在注释上询问时("select": "comment"),它评判该注释;当在代码上询问时,它评判为代码编写的注释
{"docReference": {"resolves": false}}文档注释指向不存在的代码:它引用的符号(<see cref>、{@see}、@see、Sphinx :class:)无法解析,并且项目原本应该拥有它。两个引用会被忽略,这是它与断开链接扫描器的根本区别:一个引用编译器无论如何都无法解析,另一个引用的所有者声明在扫描范围之外,因为此处无法判断该所有者是否仍然拥有该成员。解析是桥接自身的功能
{"commentLike": "*TODO*"}代码上的注释,或其上方连续注释块中的注释,匹配 glob 模式——并且,在注释本身上询问时("select": "comment"),匹配的就是那条注释的内容,因此同一个键无论规则如何到达,都能读取注释
`{"commentMatches": "(?i)\b(TODOFIXME)\b"}`
{"docTag": "deprecated"}其文档携带该标签:PHPDoc 和 JSDoc 中的 @tag、C# 中的 XML 元素、Python 文档字符串中的 Sphinx 字段或指令、或 Google/NumPy 段落
{"documented": false}它是否携带文档注释,false 表示不携带

它引用什么

检查项保留节点当
{"resolves": "App\\Models\\User"}其名称引用的内容,经过解析,正好匹配——或者,对于不命名内容的变量或属性,其持有的类:完全匹配该类,而非其子类
{"resolvesLike": "App\\Models\\*"}其名称引用的内容,经过解析,匹配 glob 模式,或者变量或属性持有的类匹配:{"resolvesLike": "*Service", "of": "child:var"} 表示任何 *Service 类型的接收者
{"declaredBy": "App\\Models\\*"}它读取的成员由 glob 模式命名的类声明:通过接收者的类型向上查找父类,直到找到声明该成员的类。这是字段的所有者,名称无法表达,当接收者类型不在扫描范围内或没有声明该成员时,检查为空——因此步骤不保留任何节点
{"answeredBy": false}声明它所读取内容的类型已经命名了它询问的问题——该类型中一个方法,其整个主体返回相同的内容,无论读取顺序如何——或者 false 表示不命名。这区分了“使用已有的名称”和“给它命名”,而询问问题的方法本身永远不会是那些询问该问题的地方之一
{"sameAs": {"of": "closest:loop", "target": "child:iter"}}它命名与同一文件中另一个节点相同的内容,通过从它跟着 of 到达节点,然后从那里跟着 target 到达——这是规则无法提出的一个问题:在循环正在遍历的集合上进行 remove,或一个参数正是刚刚读取的字段。两者必须写法相同,否则会作为相同树读取;路径末尾没有节点则不通过
{"declaredElsewhere": true}它命名的事物声明在另一个文件中,而非其所在文件;false 表示不是。在声明文件之外设置字段,绕过了类为更改自身而编写的方法,这是关于类型自身业务的规则所询问的问题;当扫描中没有声明命名它的事物时,检查为空,因此规则也会说明所有者
{"type": "number"}表达式具有的类型,按语言解读:检查器赋予的类型、其形状所示的类型、或其声明书写的类型——其中原始类型由 resolves 单独回答类
{"typeLike": "Money*"}表达式具有的类型匹配 glob 模式
{"calls": {"name": "dd"}}它自身的一个调用——而非嵌套在其中的函数内部的调用——通过该步骤
{"argument": {"at": 0, "is": "literal"}}指定位置的参数通过该步骤;负数的 at 从末尾计数,-1 是最后一个,位置不存在则不通过。没有 at 时,任意参数通过即满足,这是规则如何询问按名称传递的参数(其位置无法识别它)的方式
{"constructs": "Date*"}一个构造过程,或者在任何其他事物上的构造过程,创建了 glob 模式命名的类型的实例——在 Python 中,即调用一个类
{"unused": true}没有任何内容引用它:函数或类型(从自身外部,通过扫描解析的名称和调用以及语言调用图);参数(通过其函数内读取的名称);字段或属性(通过接收者持有的类型到达其声明,因为在大多数语言中字段不引用任何符号)。语言本身通过名称引用的成员,以及重写的成员,不被视为未使用:项目编写的内容不必命名它们
{"referredFrom": {"is": "parameter"}}引用它的内容——调用、名称或书写的类型——通过该步骤。它与 calledFrom 的区别在于询问的是引用而非其所在文件,这区分了一件事物的不同侧面:被分派命名但未被任何监听器参数命名的事件类不会被任何人听到,而 {"not": {"referredFrom": …}} 正是表达了这一点
{"calledFrom": "app/Http/**"}引用它的内容——通常是调用——位于 glob 模式匹配的文件中,读取方式与 file 相同

它位于何处

检查项保留节点当
{"file": "*Repository.php"}从项目(其所在的仓库,否则是受评判的文件夹)到它的路径,或任何尾缀,匹配 glob 模式:* 限制在文件夹内,** 可跨文件夹,如同 .gitignore
`{"fileMatches": "(^/)pages/.*(Create
{"namespaceLike": "App\\Http\\*"}它声明的命名空间、包或模块匹配 glob 模式
{"layer": "App\\Domain"}它位于项目声明的堆栈层中(backend、Python、C#)
{"topLevel": true}它位于所有函数(包括闭包)和所有类型之外——文件加载时执行的代码
{"withinLoop": true}它位于循环内部,false 表示不在
{"position": "first"}它是同级节点中的 first、last 或 only(唯一节点也属于 first 和 last)
{"field": "cond"}它填充了父节点在树中的指定槽位——语言自身对槽位的命名,例如 cond、body、else、args——这是其他检查从未询问的位置维度:作为条件出现的调用与作为参数传递的调用不同。名称是语言自身的,因此编写的规则评判该语言
{"role": "member"}它是某种类型的事物,使用每个桥接都书写的词汇:statement、expression、member(类型、函数、属性或枚举成员的任何声明)、type(书写的类型)、pattern、markup 或 other。这是规则如何表达“一个声明”而不需要为每种语言命名类型的方式,关于成员顺序或成员关系的问题需要用到它
{"descendant": {"is": "return"}}它内部的某个节点通过该步骤;使用 "within": "ownBody" 时,只进入其自身持有的节点,遇到嵌套的函数时停止,这区分了保存操作的方法与将持有保存操作的闭包传递给队列的方法
{"child": {"is": "return"}}它内部一层的某个节点通过该步骤,这是 descendant 在任意深度下无法表达的:函数自身的主体返回,而仅在 if 内部返回的函数则不匹配。使用 "field" 时,只检查填充树中该字段的子节点,如同计数的字段缩小其计数范围
{"body": {"is": "branch"}}它主体的某个语句通过该步骤,通过语言包装主体的任何方式读取:C# 编写块语句,Python 编写裸语句,因此 child 以不同方式回答,而 body 以相同方式回答。这是关于循环或函数主体的规则如何为每种语言编写一次的方式
{"inside": {"is": "catch"}}它上方的某个节点,直到文件根,通过该步骤
{"all": [{"name": "store"}, {"hasModifier": "pu...

(注:原文部分内容被截断,但此翻译已涵盖所有可翻译部分。)

相关技能